Skip to main content

Method B — Private-key JWT (Python SDK)

No shared secret ever crosses the wire. Your service signs a short-lived JWT assertion with its private key; AuthSec verifies the signature with the public key you publish.

Dashboard walkthrough

For creating the service account with a JWKS URI and granting access with screenshots, see Private-key JWT (dashboard guide).

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.

2. Build the JWKS (public key as JSON)

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.

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 '<'.

3. Environment

AUTHSEC_ISSUER=https://app.authsec.ai
PK_CLIENT_ID=<from the create dialog>
PK_PRIVATE_KEY_PATH=private_key.pem
PK_KID=key-1
MCP_URL=https://your-mcp-server.example.com/mcp
VariableWhat it doesWhere it comes from
AUTHSEC_ISSUERThe OAuth issuer. The SDK derives the token endpoint (<issuer>/oauth/token) and uses it as the assertion's aud claim.Always https://app.authsec.ai
PK_CLIENT_IDThe service account's ID. Used as iss and sub in the signed assertion. No secret -- the private key is the credential.Shown in the create-service-account dialog (dashboard guide)
PK_PRIVATE_KEY_PATHPath to your RSA private key (PEM format). The SDK signs a fresh JWT assertion with this key on each token request. Never leaves your machine.You generated it in step 1
PK_KIDThe key ID. Must match the kid field in your hosted JWKS so AuthSec picks the right public key for verification.You set it when building the JWKS in step 2
MCP_URLThe target MCP server's Resource URI. Must match the server's registered AUTHSEC_RESOURCE_URI exactly.The Resource URI from registration

4. Full program

import asyncio, os
from dotenv import load_dotenv
from authsec_sdk import AgentIdentity, PrivateKeyJwtAuth

load_dotenv()

async def main():
agent = AgentIdentity(
os.environ["AUTHSEC_ISSUER"],
os.environ["PK_CLIENT_ID"],
auth=PrivateKeyJwtAuth(
os.environ["PK_PRIVATE_KEY_PATH"],
kid=os.environ["PK_KID"],
),
)
async with agent:
token = await agent.access_for(
os.environ["MCP_URL"],
requested_scopes=["my_mcp:read"],
)
print("token:", token[:25], "…")

asyncio.run(main())

How PrivateKeyJwtAuth works

Each access_for() call signs a fresh JWT assertion with:

  • iss and sub = your client ID
  • aud = the token endpoint
  • exp = 5 minutes from now
  • jti = a unique nonce (single-use)

The assertion is sent as client_assertion in the client_credentials grant. AuthSec fetches your JWKS (cached), verifies the signature, and returns a scoped access token. An intercepted assertion is useless — it expires in minutes and can't be replayed.

The kid in your code must match the kid in your hosted JWKS.

Key rotation (no downtime)

  1. Generate a new keypair.
  2. Add the new public key to the JWKS under kid: "key-2" (keep key-1).
  3. Deploy the service with the new private key and kid="key-2".
  4. Remove the old JWKS entry.

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

Shared steps