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.
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.
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
| Variable | What it does | Where it comes from |
|---|---|---|
AUTHSEC_ISSUER | The 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_ID | The 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_PATH | Path 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_KID | The 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_URL | The 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:
issandsub= your client IDaud= the token endpointexp= 5 minutes from nowjti= 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)
- Generate a new keypair.
- Add the new public key to the JWKS under
kid: "key-2"(keepkey-1). - Deploy the service with the new private key and
kid="key-2". - Remove the old JWKS entry.
No dashboard changes needed — AuthSec always verifies against your live JWKS.