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:
| Grant | What the client presents | Who the token is about |
|---|---|---|
client_credentials | Its own credentials | The client itself |
| Token exchange | A token this OP issued | The subject of that token, narrowed or re-addressed |
| JWT bearer | A JWT signed by another party you configured as trusted | The 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
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.
| Field | Meaning |
|---|---|
JWTBearerGrant.Issuers | The closed set of trusted issuers. Non-empty, no two entries with the same Issuer. |
JWTBearerGrant.ResolveSubject | Maps the verified op.AssertionSubject (Issuer, Subject, ClientID, Claims) to a local subject. Required, and called concurrently. |
AssertionIssuer.Issuer | Matched exactly against the assertion's iss. It must not equal the OP's own issuer. |
AssertionIssuer.JWKSURI / JWKS | Where 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.AllowedClients | The client_ids allowed to redeem this issuer's assertions. Non-empty; an empty list is never read as "every client". |
AssertionIssuer.Profile | op.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:
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/tokenThe assertion's header and claims look like this:
{ "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.
| Check | Rule |
|---|---|
| Client | Authenticated and confidential. A none client is refused. |
| Issuer | The iss must be one of the configured issuers, and the client must be in that issuer's AllowedClients. |
typ | Absent, or JWT. |
| Signature | Verified against the issuer's keys. |
aud | The OP's issuer or its token endpoint URL, anywhere in the array. |
sub, exp | sub is required. exp is required, must not be past, and may be at most five minutes ahead (both with 60 seconds of leeway). |
nbf, iat | Optional; a value in the future beyond the leeway is refused. |
jti | Required, and single use: spent once the request is otherwise acceptable. |
| Scope and resource | A requested scope inside the client's registered scopes, and never openid. A resource the client has registered. |
Error codes
| Wire response | Cause |
|---|---|
400 unauthorized_client | A public client, or a client outside the issuer's AllowedClients. |
400 invalid_request | The assertion parameter is missing. |
400 invalid_grant | Any 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_scope | A scope the client has not registered, or a requested openid. |
400 invalid_target | A resource the client has not registered. |
400 access_denied | The assertion is valid but ResolveSubject returned op.ErrUnknownAssertionSubject, or the user store does not hold the subject. |
503 server_error | The issuer's JWKSURI could not be fetched. Transient; retry. |
500 server_error | A 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
cnfwhen 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.RevokeSubjectdoes not reach these tokens.ResolveSubjectand 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):extrascarries theissuerand theprofile; the actor is the resolved subject.audit.JWTBearerRejected(jwt_bearer.rejected):extras["reason"]names the stage. The verifier stages areparse,issuer,typ,signature,claims,idjagandreplay. The endpoint addspublic_client,client_not_allowed,scope,resource,binding,unknown_subject,key_source_unavailableand others. A burst ofsignaturepoints at a staleJWKS,replayat a client resubmitting, andkey_source_unavailableat 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.
Read next
- 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
jtistores are shared and sized.