Machine authentication without secrets
Let services authenticate with signed assertions (private_key_jwt), let Kubernetes pods and CI jobs use the token their platform already gives them, and hold clients to OAuth 2.1.
A machine client normally proves itself with a client secret — the one that ends up in a CI variable, a Helm value and three laptops. Base316's OIDC provider offers two ways to stop deploying that secret, plus a strict mode for clients that should follow OAuth 2.1 to the letter.
| You have | Use |
|---|---|
| A service that can hold a private key | private_key_jwt |
| A Kubernetes pod, a GitHub Actions job | a trusted issuer |
| A client that must meet OAuth 2.1 | OAuth 2.1 strict mode |
Everything here is per environment: keys, issuers and rules configured in Sandbox mean nothing in Production.
Signed assertions (private_key_jwt)
The client keeps a private key; Base316 stores only the public half. Instead of a secret, the client sends a short-lived JWT signed with that key (RFC 7523, OpenID Connect Core §9).
Set it up
-
Generate a key pair. EC P-256 is smallest; RSA must be 2048 bits or more.
openssl ecparam -name prime256v1 -genkey -noout -out client.key openssl ec -in client.key -pubout -out client.pub -
Under Settings → Applications, in OpenID Connect, choose Register application (or Edit an existing one). Set Client authentication to Private key JWT, and either paste the public key set into Public keys (JWKS) or give a JWKS URL where you publish it. A set containing a private key is refused — and that key should be treated as exposed. No client secret is issued.
Authenticate
Sign an assertion for every request. iss and sub are the client id, aud
is the token endpoint (or the issuer), exp at most five minutes ahead, and
jti unique — each assertion is accepted once.
import { randomUUID } from "node:crypto";
import { importPKCS8, SignJWT } from "jose";
const issuer = "https://api.base316.com/oidc/env_1a2b3c";
const clientId = "oidc_…";
const key = await importPKCS8(process.env.CLIENT_PRIVATE_KEY!, "ES256");
const assertion = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: "my-key-1" })
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(`${issuer}/token`)
.setIssuedAt()
.setExpirationTime("60s")
.setJti(randomUUID())
.sign(key);
const response = await fetch(`${issuer}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion,
}),
});client_id may be left out; it is read from the assertion. The same form
works at /introspect and /revoke, whose own URLs are also accepted as aud.
What is refused
- A secret, from a client registered for keys — and an assertion from a client registered for a secret. There is no falling back to the weaker method.
- An assertion used twice, one addressed to another issuer, one expiring more than five minutes out, one issued in the future.
alg: noneand every HMAC algorithm. The algorithm is decided by the key type: RS256–RS512 and PS256–PS512 for RSA, ES256 for P-256.
With a JWKS URL the key set is cached for five minutes. A token naming a key id the cache lacks triggers one refetch — at most every 30 seconds — so a rotation is picked up without waiting for the cache to expire. Publish the new key before you start signing with it.
Workload identity (trusted issuers)
Kubernetes, GitHub Actions and every major cloud already sign a token saying
which workload is running. Trust that issuer, map its subjects onto a client,
and the workload exchanges its own token for an access token with the JWT
bearer grant (RFC 7523 §2.1). It receives exactly what client_credentials
would give that client — the same audience, the same plan-narrowed scopes, the
same rate-limit claim — plus a workload claim naming who presented it:
{
"sub": "oidc_invoice_worker",
"aud": "https://api.example.com",
"scope": "invoices.write",
"workload": {
"iss": "https://oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE",
"sub": "system:serviceaccount:billing:invoice-worker"
}
}Set it up
- Under Settings → Applications, in OpenID Connect, register (or edit) the client the workload should become, with JWT bearer (workload identity) ticked under Grants, and the API it may call. This is the machine identity; nothing is ever created from a token.
- Below it, in Trusted issuers, choose Add trusted issuer:
- Issuer — exactly as the token's
issspells it. - Audience — the
audyour workload will request. It defaults to this environment's issuer URL. - Signing keys — Discover from the issuer, a JWKS URL, or Paste a key set for a private cluster whose issuer nothing outside can reach.
- Maximum token lifetime — tokens valid for longer are refused.
- Rules — a Subject and the Client it becomes, plus optional Required claims.
- Issuer — exactly as the token's
- Paste a real token into Test a token. It says which rule matched, or
which check failed and why. The token endpoint itself answers every refusal
with the same
invalid_grant, so this is where to debug.
The same configuration is available over the API as /v1/trusted-issuers
(base316.trustedIssuers in the SDK), including POST /v1/trusted-issuers/test.
Subjects. A rule's subject is exact, or a pattern in which * matches
within one segment and never crosses : or /.
system:serviceaccount:billing:* matches every service account in the
billing namespace and nothing in any other; repo:acme/* does not match
repo:acme/api:ref:refs/heads/main. Exact subjects are tried before patterns.
Kubernetes
Ask the kubelet for a projected service-account token made out to your audience. It is rotated automatically, and the pod presents it as it is.
apiVersion: v1
kind: Pod
metadata:
name: invoice-worker
namespace: billing
spec:
serviceAccountName: invoice-worker
containers:
- name: worker
image: example/invoice-worker
volumeMounts:
- name: base316-token
mountPath: /var/run/secrets/base316
readOnly: true
volumes:
- name: base316-token
projected:
sources:
- serviceAccountToken:
path: token
audience: https://api.base316.com/oidc/env_1a2b3c
expirationSeconds: 3600Trust the cluster's issuer (kubectl get --raw /.well-known/openid-configuration
shows it; on EKS, GKE and AKS it is a public URL that discovery can read). For
a cluster whose issuer is private, paste the output of
kubectl get --raw /openid/v1/jwks as the key set. Then add a rule with the
subject system:serviceaccount:billing:invoice-worker (or
system:serviceaccount:billing:*).
In the pod:
curl -s https://api.base316.com/oidc/env_1a2b3c/token \
-d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
--data-urlencode "assertion=$(cat /var/run/secrets/base316/token)" \
-d scope=invoices.writeUnlike a client assertion, a workload token may be presented more than once: the pod reuses its projected token until the kubelet replaces it.
GitHub Actions
Trust https://token.actions.githubusercontent.com with discovery, and pin
the repository by more than its name — a fork can have the same one:
- Subject
repo:acme/api:environment:production - Required claims
repository_owner_id=123456
permissions:
id-token: write
steps:
- run: |
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.base316.com/oidc/env_1a2b3c" | jq -r .value)
curl -s https://api.base316.com/oidc/env_1a2b3c/token \
-d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
--data-urlencode "assertion=$TOKEN"OAuth 2.1 strict mode
Tick OAuth 2.1 strict mode on a client, in Register application or Edit. The client then must:
- send PKCE (
S256) on every authorization request, confidential clients included — a code issued without one is refused at the token endpoint; - send
redirect_uriwhen it redeems a code; - accept rotating refresh tokens (rotation is forced on);
- authenticate with HTTP Basic or
private_key_jwt, never withclient_secretin the request body.
Every dynamically registered client is strict. One that asks to register with
client_secret_post is registered with client_secret_basic instead, and the
registration response says so.

