Skip to content

Use case — JWT bearer grant (RFC 7523) ​

The JWT bearer grant (RFC 7523 §2.1) lets a client trade a signed JWT, an assertion minted by a party the OP trusts, for an access token. No browser is involved: the client posts the assertion to /token and the OP verifies it against the issuer's keys. The usual case is a partner's identity provider or an internal service that already knows who the user is and wants an access token for that user at your authorization server.

When to use it ​

Three grants look alike from a distance. They differ in who vouches for the subject:

GrantWhat the client presentsWho the token is about
client_credentialsIts own credentialsThe client itself
Token exchangeA token this OP issuedThe subject of that token, narrowed or re-addressed
JWT bearerA JWT signed by another party you configured as trustedThe account the assertion's sub maps to

Use it when the proof of identity lives outside this OP and a signed statement is enough. If the user can be sent through a browser, the authorization code flow is simpler. If the incoming token was issued by this OP, use token exchange.

Assertion — what's that?

An assertion is a short-lived JWT that says "issuer X vouches that subject Y is this person". The OP does not log anybody in: it checks the signature against keys you declared for issuer X, checks that the assertion was meant for this OP and has not been used before, then maps sub to a local account through your callback.

Enabling the grant ​

go
import (
  "context"

  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

st := inmem.New()

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(st),
  // Every subject ResolveSubject returns is confirmed through the user
  // store, so name it.
  op.WithUserStore(st.Users()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),
  op.WithGrants(grant.ClientCredentials),

  op.WithJWTBearerGrant(op.JWTBearerGrant{
    Issuers: []op.AssertionIssuer{{
      Issuer:         "https://idp.partner.example",
      JWKSURI:        "https://idp.partner.example/jwks.json",
      AllowedClients: []string{"partner-sync"},
    }},
    ResolveSubject: func(ctx context.Context, a op.AssertionSubject) (string, error) {
      local, ok := lookupLocalAccount(ctx, a.Issuer, a.Subject)
      if !ok {
        return "", op.ErrUnknownAssertionSubject
      }
      return local, nil
    },
  }),

  op.WithStaticClients(op.ConfidentialClient{
    ID:         "partner-sync",
    Secret:     partnerSecret,
    AuthMethod: op.AuthClientSecretBasic,
    // The client must list the jwt-bearer URN.
    GrantTypes: []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
    Scopes:     []string{"records:read"},
  }),
)

op.WithJWTBearerGrant(...) adds grant.JWTBearer to the grant set (whatever order the options come in) and advertises the URN in grant_types_supported. Discovery never lists the trusted issuers.

FieldMeaning
JWTBearerGrant.IssuersThe closed set of trusted issuers. Non-empty, no two entries with the same Issuer.
JWTBearerGrant.ResolveSubjectMaps the verified op.AssertionSubject (Issuer, Subject, ClientID, Claims) to a local subject. Required, and called concurrently.
AssertionIssuer.IssuerMatched exactly against the assertion's iss. It must not equal the OP's own issuer.
AssertionIssuer.JWKSURI / JWKSWhere the issuer's keys come from: an https URL, or an inline JWK Set. Exactly one of the two. A URL is fetched under the same network policy as a client's jwks_uri (op.WithAllowPrivateNetwork(op.OutboundJWKS) opens private addresses).
AssertionIssuer.AllowedClientsThe client_ids allowed to redeem this issuer's assertions. Non-empty; an empty list is never read as "every client".
AssertionIssuer.Profileop.AssertionProfileJWT (the zero value) is plain RFC 7523. The other profile is covered on the ID-JAG page.

Misconfiguration fails at op.New

An empty Issuers, a nil ResolveSubject, an issuer equal to the OP's own, a plain-http JWKSURI, both or neither of JWKSURI and JWKS, an empty AllowedClients, a missing user store, and grant.JWTBearer in op.WithGrants without op.WithJWTBearerGrant are all refused at start-up. op.WithJWTBearerGrant may be given once, and op.WithCustomGrant cannot reuse the jwt-bearer URN.

ResolveSubject is where your account model enters. Return op.ErrUnknownAssertionSubject when the assertion is valid but names nobody you have provisioned; that answers access_denied. Any other error, or an empty subject, answers 500 server_error. A subject the user store does not hold also answers access_denied.

A worked redemption ​

The client authenticates as itself and posts the assertion:

sh
curl -s \
  -u partner-sync:<secret> \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
  -d "assertion=$ASSERTION" \
  -d 'scope=records:read' \
  https://op.example.com/oidc/token

The assertion's header and claims look like this:

json
{ "alg": "ES256", "typ": "JWT", "kid": "partner-2026" }
{
  "iss": "https://idp.partner.example",
  "sub": "u-7731",
  "aud": "https://op.example.com",
  "iat": 1790000000,
  "exp": 1790000240,
  "jti": "5d0c7c1e-9b0a-4a63-8f67-2b8f4c9a1d11"
}

The response is a bearer access token for the account ResolveSubject returned, scoped to records:read. When scope is omitted, the client's registered scopes without openid are used.

What the OP checks ​

The OP verifies the request in a fixed order. Everything the client could fix by changing its request is checked before the assertion's jti is spent, so a malformed call does not burn a good assertion.

CheckRule
ClientAuthenticated and confidential. A none client is refused.
IssuerThe iss must be one of the configured issuers, and the client must be in that issuer's AllowedClients.
typAbsent, or JWT.
SignatureVerified against the issuer's keys.
audThe OP's issuer or its token endpoint URL, anywhere in the array.
sub, expsub is required. exp is required, must not be past, and may be at most five minutes ahead (both with 60 seconds of leeway).
nbf, iatOptional; a value in the future beyond the leeway is refused.
jtiRequired, and single use: spent once the request is otherwise acceptable.
Scope and resourceA requested scope inside the client's registered scopes, and never openid. A resource the client has registered.

Error codes ​

Wire responseCause
400 unauthorized_clientA public client, or a client outside the issuer's AllowedClients.
400 invalid_requestThe assertion parameter is missing.
400 invalid_grantAny defect of the assertion: unknown issuer, wrong typ, bad signature, aud, exp, nbf, a missing or already-used jti. The response does not say which.
400 invalid_scopeA scope the client has not registered, or a requested openid.
400 invalid_targetA resource the client has not registered.
400 access_deniedThe assertion is valid but ResolveSubject returned op.ErrUnknownAssertionSubject, or the user store does not hold the subject.
503 server_errorThe issuer's JWKSURI could not be fetched. Transient; retry.
500 server_errorA resolver or user-store fault.

The failing stage is recorded as the reason of the audit event, not on the wire.

The issued token ​

  • No refresh token and no ID Token. A client whose access token expires obtains a fresh assertion and redeems it. A replayed assertion is refused, so the client goes back to the issuer for a new one.
  • Sender constraint follows the request. The access token carries cnf when the redemption presents a DPoP proof or a client certificate, and is a bearer token otherwise. See Sender constraint.
  • Not backed by a grant record. Provider.RevokeSubject does not reach these tokens. ResolveSubject and the user-store check stop the next issuance; a token already issued lives out its normal TTL.

Operating it ​

jti single use runs through the ConsumedJTIStore substore, in its own namespace keyed by issuer and jti. Replicas must share it. Otherwise each node accepts the same assertion once, and the replay window becomes the assertion's lifetime. The in-memory store is per process, so a multi-node deployment needs a shared backend (a shared Redis with noeviction is the usual choice); see Clock and replay.

Two events cover the grant, both in the audit event catalog:

  • audit.JWTBearerGranted (jwt_bearer.granted): extras carries the issuer and the profile; the actor is the resolved subject.
  • audit.JWTBearerRejected (jwt_bearer.rejected): extras["reason"] names the stage. The verifier stages are parse, issuer, typ, signature, claims, idjag and replay. The endpoint adds public_client, client_not_allowed, scope, resource, binding, unknown_subject, key_source_unavailable and others. A burst of signature points at a stale JWKS, replay at a client resubmitting, and key_source_unavailable at the issuer's JWKS endpoint.

Key rotation on the issuer's side is free when you use JWKSURI. With an inline JWKS you redeploy to rotate.

The same grant also redeems an Identity Assertion JWT Authorization Grant, minted by one OP and redeemed at another. That profile is experimental; see ID-JAG.

  • Token Exchange: the grant to use when the incoming token came from this OP.
  • Custom Grant: the URN-routing seam for grants the library does not ship.
  • ID-JAG: the experimental identity-chaining profile built on this grant.
  • Clock and replay: how jti stores are shared and sized.