Skip to main content

Service accounts: private-key JWT

~15 minutes. No shared secret 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.

Full SDK code

For the complete runnable program and API details, see Private-key JWT (Python SDK).

How it works

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 -- 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. Generate it from public_key.pem:

from cryptography.hazmat.primitives import serialization
import base64, json

pub = serialization.load_pem_public_key(open("public_key.pem", "rb").read())
n, e = pub.public_numbers().n, pub.public_numbers().e
b64u = lambda i, l: base64.urlsafe_b64encode(i.to_bytes(l, "big")).rstrip(b"=").decode()
print(json.dumps({"keys": [{
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "key-1",
"n": b64u(n, (n.bit_length() + 7) // 8), "e": b64u(e, 3),
}]}, indent=2))

Host the JSON anywhere public -- 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 references when signing.

JWKS URI must return raw JSON

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

No secret to save -- the dialog hands you only a CLIENT_ID. 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 access -> Machine 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.

Verify

  • Application -> Connections tab shows the account as an active (m2m) connection with its role and scopes.
  • MONITOR -> M2M Logs shows each token grant as it happens.
  • A tools/list call with the token returns only the tools the granted scopes allow.

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.

Revoking

Application -> Access tab -> Who has access -> the account's ... menu -> Revoke access. This removes access to this application only; other grants are untouched.

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.