Skip to main content

Service accounts: private-key JWT

Time: ~15 minutes. No shared secret ever crosses the wire: your service signs a short-lived JWT assertion with its private key; AuthSec verifies it with the public key you publish. The enterprise-posture upgrade from client secret.

Step 1 — Generate a keypair

openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem

private_key.pem stays on the machine that runs your service. Never commit it, never upload it anywhere — including to AuthSec. The private key is the credential.

Step 2 — Host the public key as a JWKS

AuthSec fetches your public key from a URL you control, in JWKS format — your own domain (https://example.com/.well-known/jwks.json), an S3 bucket, or a GitHub gist for testing. Each key entry carries a kid (key id) that your service will reference when signing.

A one-time Python snippet that converts public_key.pem into the JWKS JSON is in the SDK guide.

⚠️ The JWKS URI must return raw JSON, not an HTML page. With a gist, use the raw URL (gist.githubusercontent.com/.../raw/.../jwks.json) — the normal gist page URL serves HTML and verification fails with parse JWKS: invalid character '<'.

Step 3 — Create the service account with the JWKS URI

WORKSPACE → Service Accounts → + Create service account. Pick Private-key JWT as the auth method — a JWKS URI field appears; paste your URL:

Create service account — private-key JWT + JWKS URI

This time there is no secret to save — the dialog hands you only a CLIENT_ID. There's nothing confidential between you and AuthSec; your private key never leaves your machine:

Private-key JWT credentials — client id only

Step 4 — Grant access

Same as every machine identity: application → Access tab → Add accessMachine credential (secret/key) → pick the account and a role. Without a grant, token requests fail with access_denied.

Step 5 — Sign and call

from authsec_sdk import AgentIdentity, PrivateKeyJwtAuth

agent = AgentIdentity(
ISSUER, PK_CLIENT_ID,
auth=PrivateKeyJwtAuth("private_key.pem", kid="key-1"), # kid must match your JWKS
)
async with agent:
token = await agent.access_for(MCP_URL, requested_scopes=["my_mcp:read"])

Each request signs a fresh assertion — 5-minute lifetime, single-use jti, audience-bound to the token endpoint — so an intercepted assertion is useless. Full program and details: Python SDK — private-key JWT.

Key rotation (no downtime)

  1. Generate a new pair; add the new public key to the JWKS under kid: "key-2" (keep key-1 in place).
  2. Deploy the service with the new private key and kid="key-2".
  3. Remove the old JWKS entry.

No dashboard changes needed — AuthSec always verifies against your live JWKS.

Troubleshooting

ErrorCauseFix
JWKS resolution failed: parse JWKS: invalid character '<'JWKS URI returns HTML (gist page URL, 404 page)Point at raw JSON — see the warning in step 2
Signature verification failskid mismatch between code and JWKS, or wrong key deployedMake PrivateKeyJwtAuth(kid=...) match the JWKS entry
access_denied: client not authorized for this resource serverNo grant on the target applicationStep 4

Next

Running in Kubernetes? Drop the stored key entirely:

→ Kubernetes / SPIFFE — the platform attests the pod; nothing to leak, nothing to rotate.