Service authentication
Nebula accepts API keys and bearer JWTs through the standardAuthorization header:
| Setting | Purpose |
|---|---|
NEBULA_JWT_KID | Key ID written into the JWT header as kid. Use a stable, deployment-scoped value such as nebula-2026-05. |
NEBULA_JWT_PRIVATE_KEY_PEM | Active RSA private key used to sign access tokens. Store this only in your deployment secret manager. |
NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON | JSON array of retired public keys kept valid during a rotation overlap window. |
NEBULA_ACCESS_LIFE_IN_MINUTES | Access-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_DAYS | Refresh-token lifetime. Defaults to 7 days if unset. |
alg: RS256kid: <NEBULA_JWT_KID>sub: Nebula user UUIDemail: normalized user emailtoken_type: accessexp: token expiration
JWKS endpoint
The runtime exposes the public key set at: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:
- Choose a new
NEBULA_JWT_KID, for examplenebula-YYYY-MM. - Add the new
NEBULA_JWT_KIDand private key contents asNEBULA_JWT_PRIVATE_KEY_PEMin your secret manager. - Move the previous public key into
NEBULA_JWT_RETIRED_PUBLIC_KEYS_JSON. - Deploy the updated secrets to every Nebula runtime.
- Keep retired public keys published for at least the access-token lifetime plus the JWKS cache lifetime and deployment skew.
- 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:
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
| Status | Meaning |
|---|---|
400 | The request supplied conflicting auth methods, such as both a Bearer token and X-API-Key. |
401 | Credentials are missing, malformed, expired, signed by an unknown key, blacklisted, or otherwise invalid. |
403 | Authentication 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:- 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.gpgfrom GPG-mode or a plain.tar.gzfrom clear-channel mode). - 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.
- 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);--repoalone scopes only to repository identity.
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
2. Decryption
Two delivery modes, picked per-customer by us at ship time:- GPG asymmetric
- Clear-channel
Email includes a Possession of the decryption key proves the bundle was encrypted to you — no third party can read it.
.tar.gz.gpg file. Decrypt with the GPG private key matching the public key you provided us at onboarding:3. Per-image build provenance (optional)
The bundle ships anattestations/ directory:
| File | Purpose |
|---|---|
attestations/<image>.sigstore.jsonl | Sigstore attestation bundle (SLSA build provenance) for one image, written by GitHub’s actions/attest-build-provenance |
attestations/manifest.txt | Maps bundle filename → image digest → original image tag |
attestations/trusted_root.jsonl | Sigstore TUF root snapshotted at bundle-build time, so verification runs fully offline |
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:
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 runcosign 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 bundledattestations/<image>.sigstore.jsonlas a referrer of the image.cosign verify-attestation— verify with the same--certificate-identity-regexpand--certificate-oidc-issuerpinning 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.jsonlto 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 includessbom.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’sSECURITY.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.