Base 3:16
Guides

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 haveUse
A service that can hold a private keyprivate_key_jwt
A Kubernetes pod, a GitHub Actions joba trusted issuer
A client that must meet OAuth 2.1OAuth 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

  1. 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
  2. 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: none and 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

  1. 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.
  2. Below it, in Trusted issuers, choose Add trusted issuer:
    • Issuer — exactly as the token's iss spells it.
    • Audience — the aud your 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.
  3. 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: 3600

Trust 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.write

Unlike 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_uri when it redeems a code;
  • accept rotating refresh tokens (rotation is forced on);
  • authenticate with HTTP Basic or private_key_jwt, never with client_secret in 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.

On this page