Skip to main content

Service authentication

Nebula accepts API keys and bearer JWTs through the standard Authorization header:
Authorization: Bearer <token>
Enterprise deployments sign Nebula access tokens with RS256 and publish the active public keys through JWKS. This lets internal services and customer-controlled verifiers validate access tokens without sharing Nebula’s symmetric server secret.
SettingPurpose
NEBULA_JWT_KIDKey ID written into the JWT header as kid. Use a stable, deployment-scoped value such as nebula-2026-05.
NEBULA_JWT_PRIVATE_KEY_PEMActive RSA private key used to sign access tokens. Store this only in your deployment secret manager.
NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSONJSON array of retired public keys kept valid during a rotation overlap window.
NEBULA_ACCESS_LIFE_IN_MINUTESAccess-token lifetime. Shipped Enterprise configs set 60 minutes. If the TOML config omits access_token_lifetime_in_minutes, this env var overrides the code fallback of 3600 minutes.
NEBULA_REFRESH_LIFE_IN_DAYSRefresh-token lifetime. Defaults to 7 days if unset.
Access tokens are JWTs with:
  • alg: RS256
  • kid: <NEBULA_JWT_KID>
  • sub: Nebula user UUID
  • email: normalized user email
  • token_type: access
  • exp: token expiration
Refresh tokens and other internal tokens are server-internal and are not intended for external service verification.

JWKS endpoint

The runtime exposes the public key set at:
/.well-known/jwks.json
The endpoint is public and cacheable for one hour (Cache-Control: public, max-age=3600). External verifiers should:
  • Fetch keys from /.well-known/jwks.json
  • Select the verification key by JWT kid
  • Require alg=RS256
  • Require and validate exp
  • Reject HS256 access tokens
  • Reject tokens with missing or unknown kid

Key rotation

For a fresh Docker Compose deployment, ./enterprise/generate-secrets.sh creates NEBULA_JWT_KID and the RSA-2048 private key used by the stack. For Kubernetes deployments, store the same values in your configured secret backend. To rotate manually, generate a new RSA-2048 keypair and public key:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
  -out nebula-jwt-private.pem

openssl rsa -in nebula-jwt-private.pem -pubout \
  -out nebula-jwt-public.pem
Then:
  1. Choose a new NEBULA_JWT_KID, for example nebula-YYYY-MM.
  2. Add the new NEBULA_JWT_KID and private key contents as NEBULA_JWT_PRIVATE_KEY_PEM in your secret manager.
  3. Move the previous public key into NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON.
  4. Deploy the updated secrets to every Nebula runtime.
  5. Keep retired public keys published for at least the access-token lifetime plus the JWKS cache lifetime and deployment skew.
  6. Remove a retired key only after every token signed by that key must have expired.
NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON is an array of public keys:
[
  {
    "kid": "nebula-2026-04",
    "public_key_pem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
  }
]
Do not rotate by deleting the old public key immediately. Any service that cached the old JWKS, or any user still holding an unexpired access token signed by the old key, will receive authentication failures until the overlap window has elapsed.

Auth error semantics

StatusMeaning
400The request supplied conflicting auth methods, such as both a Bearer token and X-API-Key.
401Credentials are missing, malformed, expired, signed by an unknown key, blacklisted, or otherwise invalid.
403Authentication succeeded, but the user is not authorized for the requested resource or operation.

Bundle integrity

Every Nebula Enterprise bundle ships with three independent integrity layers:
  1. Bundle SHA256 — proves the bytes you downloaded match what we delivered (we publish the SHA of the delivered file, whether that’s a .tar.gz.gpg from GPG-mode or a plain .tar.gz from clear-channel mode).
  2. Bundle decryption — restores the original tarball using the GPG public key you provided at onboarding (we encrypt to that key). This gives confidentiality and proves possession of the agreed key material; sender authenticity ultimately rests on the integrity of that out-of-band key exchange combined with the SHA256 in (1), not on decryption alone.
  3. Per-image Sigstore attestations — prove the Nebula runtime, graph-engine, and bundled Postgres images were built by our pinned image-build workflows on zeroset-inc/nebula, signed by GitHub’s Sigstore identity. Workflow-level pinning requires --signer-workflow (see below); --repo alone scopes only to repository identity.
Step (1) is always required. Step (2) is required for GPG-encrypted deliveries; clear-channel deliveries skip it (integrity rests on (1) alone, and we only use that mode when the delivery URL is itself authenticated). Step (3) is optional and intended for security review / audit pipelines.

1. Bundle SHA256

The delivery email includes the SHA256 of the file you downloaded. The filename’s suffix depends on the delivery mode we picked:
  • GPG asymmetric: .tar.gz.gpg
  • Clear-channel: .tar.gz
Verify before decryption:
echo "<sha256-from-email>  <delivered-file>" | sha256sum -c
A mismatch means the bytes were tampered with in transit or storage; stop and contact us.

2. Decryption

Two delivery modes, picked per-customer by us at ship time:
Email includes a .tar.gz.gpg file. Decrypt with the GPG private key matching the public key you provided us at onboarding:
gpg --decrypt nebula-enterprise-<version>-<customer>.tar.gz.gpg \
  > nebula-enterprise-<version>.tar.gz
Possession of the decryption key proves the bundle was encrypted to you — no third party can read it.

3. Per-image build provenance (optional)

The bundle ships an attestations/ directory:
FilePurpose
attestations/<image>.sigstore.jsonlSigstore attestation bundle (SLSA build provenance) for one image, written by GitHub’s actions/attest-build-provenance
attestations/manifest.txtMaps bundle filename → image digest → original image tag
attestations/trusted_root.jsonlSigstore TUF root snapshotted at bundle-build time, so verification runs fully offline
The canonical verification path uses the gh CLI (recipe below). cosign 2.x can also verify these bundles for environments where gh isn’t available — see the “Cosign 2.x” section below. Both tools verify against an OCI image reference at a specific digest — they do not consult the local Docker daemon. Source the image into a registry the verifier can reach: either re-push the image directly into your internal registry, or docker load -i images.tar followed by a re-tag and docker push to that registry. Then run the verify command against the registry reference (<registry>/<image>@sha256:<digest>).

gh CLI

--repo scopes the cert-identity check to a repository; --signer-workflow additionally pins which workflow produced the attestation. Use both for the strictest check. The right --signer-workflow depends on which image you’re verifying — attestations/manifest.txt maps each bundled file to its original image tag:
# nebula runtime + migration images (signed by build-and-push-ecr.yml):
gh attestation verify \
  oci://<registry>/<image>@sha256:<digest> \
  --bundle attestations/<image>.sigstore.jsonl \
  --custom-trusted-root attestations/trusted_root.jsonl \
  --repo zeroset-inc/nebula \
  --signer-workflow zeroset-inc/nebula/.github/workflows/build-and-push-ecr.yml

# graph-engine images (signed by build-graph-engine-ecr.yml):
gh attestation verify \
  oci://<registry>/<image>@sha256:<digest> \
  --bundle attestations/<image>.sigstore.jsonl \
  --custom-trusted-root attestations/trusted_root.jsonl \
  --repo zeroset-inc/nebula \
  --signer-workflow zeroset-inc/nebula/.github/workflows/build-graph-engine-ecr.yml

# Postgres image (signed by build-postgres-ecr.yml):
gh attestation verify \
  oci://<registry>/<image>@sha256:<digest> \
  --bundle attestations/<image>.sigstore.jsonl \
  --custom-trusted-root attestations/trusted_root.jsonl \
  --repo zeroset-inc/nebula \
  --signer-workflow zeroset-inc/nebula/.github/workflows/build-postgres-ecr.yml
A successful run prints Loaded digest sha256:... ... ✓ Verification succeeded!. Failure exit codes are non-zero with a SAN / cert-claim diagnostic. Dropping --signer-workflow is safe but weakens the check to “some workflow in zeroset-inc/nebula signed this image” rather than “the expected build workflow signed this image”.

Cosign 2.x

Cosign’s image-attestation flow expects the attestation to be discoverable as an OCI referrer alongside the image. Push the bundle to your internal registry as the image’s referrer (one-time per image), then run cosign verify-attestation against the OCI reference. The exact cosign attach attestation and cosign verify-attestation --type slsaprovenance invocations are documented upstream:
  • cosign attach attestation — push the bundled attestations/<image>.sigstore.jsonl as a referrer of the image.
  • cosign verify-attestation — verify with the same --certificate-identity-regexp and --certificate-oidc-issuer pinning shown in the gh recipe above (regex: ^https://github\.com/zeroset-inc/nebula/\.github/workflows/build-(and-push|graph-engine|postgres)-ecr\.yml@, issuer: https://token.actions.githubusercontent.com). Pass --trusted-root attestations/trusted_root.jsonl to keep verification fully offline.
We recommend gh attestation verify (above) over cosign for routine verification — it accepts the shipped bundle file directly via --bundle <path> against an OCI image reference in your registry, and skips cosign’s separate attach attestation step.

Offline vs online verification

Both recipes above use --custom-trusted-root / --trusted-root against the bundled trusted_root.jsonl. Without that flag, the tool fetches Sigstore’s live TUF root online — that’s fine for connected environments but breaks for air-gapped audits. Always prefer the bundled root for reproducible verification.

SBOM

Each bundle includes sbom.spdx.json — an SPDX 2.3 SBOM covering every container image. Suitable for ingestion by Snyk, Trivy, Grype, or any other SPDX-compatible scanner.

Responsible disclosure

The bundle’s SECURITY.md carries the current responsible-disclosure contact and supported-version policy. Vulnerabilities affecting current shipped versions get an out-of-band patched bundle on the standard CVE timeline; see SECURITY.md for the specifics.