OAuth2 and OpenID Connect

On this page

OAuth 2.0 defines how applications obtain authorization to access resources; OpenID Connect (OIDC) defines the representation and verification of user authentication results on top of it. An application can request only API authorization, or it can log in via OIDC and then establish its own session. JWT is a token representation format; it is neither a synonym for OAuth nor a promise of "statelessness."

RoleExampleCore Responsibility
Resource OwnerUser who owns the documentAuthorizes access; not all flows involve user participation
ClientApplication requesting document accessInitiates authorization, maintains transactions, holds and uses tokens
Authorization ServerOrganization's identity and authorization serviceAuthenticates, makes authorization decisions, issues and manages tokens
Resource ServerDocument APIValidates tokens and enforces resource-level permissions

The authorization server and resource server may belong to the same product or be deployed independently. The browser primarily handles user interaction and redirection; in server-side web modes, the client that actually exchanges the authorization code and saves the token is typically the application backend. RFC 6749

Explaining Authorization Code + PKCE with a Single Login

Authorization Code Login: Front channel carries code, client exchanges it and establishes local session Browser Application Client Authorization Server ① Save transaction, redirect ② authorize: challenge, state, nonce ③ Post-login callback: code, state ④ Associate local transaction ⑤ token: code, verifier, client authentication ⑥ Validate ID Token, create application session Diagram uses server-side web client; SPA and native apps have different token storage and client authentication capabilities.

Before starting the login, the application creates a short-lived transaction, recording the expected issuer, callback URL, random state, nonce, and PKCE verifier, and binds it to the browser session initiating the login. Since different tabs can log in concurrently, you cannot use a single global variable to save the "last state."

StepKey ContentCompletion Condition
Initiate Authorizationresponse_type=code, client_id, redirect_uri, scopeParameters match registration and local transaction
PKCE Bindingcode_challenge, code_challenge_method=S256Verifier stays with client, challenge sent to authorization server
User InteractionLogin and applicable consentExisting sessions may skip credential entry; not always a popup
Handle Callbackcode or error, along with returned stateFind and verify initiating transaction; reject expired or unrelated responses
Exchangecode, verifier, applicable redirect_uri and client authenticationAuthorization server verifies binding; authorization code is single-use
Establish Application SessionValidate OIDC ID Token, map to local accountThen create application's own session and permission context

The client must also associate error callbacks with the original transaction. Do not skip association checks just because error=access_denied was returned. Production implementations must also handle duplicate callbacks, lost code-exchange responses, and transaction expiration, rather than simply replaying the same authorization code.

OAuth security best practices require authorization servers to support PKCE; public clients must use PKCE; confidential clients should also use it. The implicit flow has not disappeared from all systems, but returning access tokens directly in the authorization response is no longer the standard choice for new implementations; the password grant should explicitly not be used. Machine identities, device interactions, etc., still have their own authorization methods; do not call the code flow the "only flow for all scenarios." RFC 9700 §2

How is the PKCE value calculated?

The S256 calculation is BASE64URL(SHA256(ASCII(code_verifier))), removing trailing = from Base64. The verifier length is 43–128 allowed characters, generated by a sufficiently random number; the challenge is not a hexadecimal string of SHA-256.

import base64
import hashlib
import secrets

def challenge(verifier):
    return base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode('ascii')).digest()
    ).rstrip(b'=').decode('ascii')

# Public vector from RFC 7636 Appendix B, not a secret used at runtime.
verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
assert challenge(verifier) == expected
assert challenge(verifier + 'x') != expected
fresh = secrets.token_urlsafe(32)
assert len(fresh) == 43 and len(challenge(fresh)) == 43
print('PKCE public vector and modification rejection passed')

An interceptor who obtains the code but lacks the corresponding verifier cannot complete the exchange using only the code. PKCE is not long-term client authentication: publicly distributed SPAs or native apps cannot rely on embedded client_secret for secrecy; server-side clients that can protect secrets still authenticate themselves according to registration. RFC 7636

What do state, nonce, and PKCE bind respectively?

ValueAssociated LocationRelationship to Verify
stateAuthorization request and browser callbackDoes this callback belong to the transaction initiated by this browser?
nonceOIDC request and ID TokenIs the authentication result in the token bound to the expected transaction?
PKCEAuthorization request and token exchangeDoes the exchanger hold the verifier generated at that time?

The protective effects of the three overlap; it is incorrect to simply say "missing any one of them is definitely a violation in all protocol modes." This article's examples explicitly use all three to facilitate verifying association relationships separately. Especially in multi-issuer scenarios, prevent handing A's response to B's token endpoint: the expected issuer should be determined from trusted transactions and configuration, not just trusting any address given in the callback.

Why is the challenge not the hexadecimal of SHA256?

These two outputs come from the same 32-byte digest, but only the Base64url output is the challenge required by the protocol. Changing one allowed character changes the binding result; shortening the verifier to below 43 characters first violates syntax requirements. You cannot treat the hash of an arbitrary string as a valid PKCE.

Preparing the visual
Why is the challenge not the hexadecimal of SHA256?

The digest is 32 bytes; hex and Base64url are different encodings. Only obtaining the binding challenge according to S256 rules satisfies this exchange check.

ID Token, Access Token, and Refresh Token Are Not Interchangeable

Recipients and Uses of Three Types of Tokens ID Token → Client Proves an authentication result; aud usually contains client_id Access Token → Resource Server Grants permission to access API; may be JWT or opaque string Refresh Token → Authorization Server Exchange for subsequent tokens; not sent to business APIs, nor guaranteed to be issued Similar shapes do not mean interchangeable; signature validity also requires satisfying recipient, purpose, time, and authorization constraints.

The ID Token is for OIDC clients, stating the user's authentication result; its validity period does not equal the mandatory validity period of the application session, nor does it replace permission checks for each API. The Access Token is for resource servers; it can contain user information, and may also represent clients without interactive users. Whether it contains user claims depends on the token format and authorization scenario; do not infer from the name.

OIDC's scope=openid indicates a request for OIDC authentication; profile, email, etc., are requests for claims and do not guarantee that all fields will appear. email may change and is not inherently globally unique; an application's external identity key should be based on a combination of trusted iss and sub, followed by local account mapping. When calling UserInfo, verify that its sub matches the already validated ID Token. OIDC Core

Requesting offline_access does not mean the server unconditionally returns a refresh token; issuance depends on the flow, user consent, and provider policy. A token response without a refresh token is not necessarily a protocol error.

JWT Validation: Decide Who to Trust First, Then Choose the Key

Commonly signed JWTs use the JWS compact three-part form: Base64url(header), Base64url(payload), signature. Encoding is not encryption; anyone obtaining such a token can usually read the claims; encrypted JWTs use a different structure and cannot be summarized as "counting three dots."

kid is a hint for finding candidate keys in the trusted key set, not a certificate, nor an arbitrary key retrieval URL. Clients should obtain jwks_uri from the trusted issuer's discovery metadata and verify the issuer in the metadata; the JWKS path is not fixed to /.well-known/jwks.json. Do not arbitrarily access an issuer or jku based on an unverified token. OIDC Discovery

Validation LayerJudgment Content
Cryptography and FormatAllowed algorithms, signatures, key usage, parsing, and duplicate field policies
Trust SourceIssuer must exactly match expected configuration
RecipientID Token's aud contains this client; Access Token is for the current API
Timeexp, applicable nbf, acceptable clock skew, and authentication freshness
Transaction BindingIf nonce was requested, verify its value; handle azp and other corresponding rules
Token PurposeUse different validation rules for ID Token and Access Token
Business Authorizationscope, subject, tenant, resource ownership, and actions

During key rotation, you can refresh the cache for unknown kids to a limited extent, while avoiding triggering infinite external requests for every strange kid. The retention period for old public keys must cover old tokens that should still be accepted; deleting verification keys is not the same as letting tokens expire naturally. JWT BCP

JWT Access Tokens compliant with RFC 9068 use the at+jwt type and define corresponding claims and validation requirements; you cannot require all historical or private access tokens to automatically follow this profile, nor can you accept "any JWT with a correct signature" as API credentials. RFC 9068

The following only demonstrates business authorization after the trusted token has been verified, deliberately not treating the JSON dictionary as a complete token validator. Even if scope allows reading, you must check if the document belongs to the current tenant.

def may_read(claims, document, now):
    return (
        claims['iss'] == 'https://identity.example'
        and claims['aud'] == 'documents-api'
        and now < claims['exp']
        and 'documents:read' in claims['scope'].split()
        and claims['tenant_id'] == document['tenant_id']
    )

c = dict(iss='https://identity.example', aud='documents-api',
         exp=2000, scope='documents:read', tenant_id='tenant-a')
assert may_read(c, {'tenant_id': 'tenant-a'}, 1999)
assert not may_read(c, {'tenant_id': 'tenant-b'}, 1999)
assert not may_read(c, {'tenant_id': 'tenant-a'}, 2000)
assert not may_read(dict(c, aud='web-client'), {'tenant_id': 'tenant-a'}, 1999)
print('Cross-tenant, expired, and wrong recipient all rejected')

Refresh, Revocation, and Logout Are Three Different Things

After the Access Token expires, the client can use the refresh token to request a new token from the authorization server. If rotation is used, each refresh returns a new refresh token and invalidates the old one; the client needs to atomically save the new value and coordinate concurrent refreshes. Two requests using the old token simultaneously may cause one to be seen as a reuse, thereby revoking the entire token family.

Reuse detection also encounters the ambiguity of "server has rotated, but response was lost in the network." Do not retry the old value infinitely; the provider's recovery window, reuse policy, and client re-authentication path must be consistent. Refresh tokens for public clients should use sender constraints or rotation as specified replay protection; simply putting the token into client storage is not this mechanism. RFC 9700 §4.14

OperationState Directly ChangedNot Automatically Guaranteed
Delete Application SessionThis application's login statusAll API tokens are invalidated
Revoke Refresh TokenSubsequent refresh capability and provider-related authorization statusIssued JWTs are immediately rejected at all APIs
Authorization Server LogoutIdentity Provider sessionEach application's local session disappears synchronously
Revoke Access TokenValidity in the authorization serverAPIs relying only on offline signature verification are not instantly aware

If the API only checks JWT signature and expiration time, it may still accept it after revocation until expiration; for faster convergence, explicit solutions such as introspection, revocation status propagation, or shorter validity periods can be used. The active returned by introspection also depends on its context, and caching will cause visible delays. Token Introspection, Token Revocation

Bearer token holders can use it; DPoP binds the token to a proof key and generates a proof for the request, changing this usage condition. However, it does not replace scope or resource authorization, nor does it allow a fully controlled client to automatically regain trust. DPoP

Application Form Determines Where Tokens Are Stored

FormIdentity and Storage MethodIssues to Handle
Server-side Web / BFFBackend exchanges and saves token, browser holds application session cookieCookie attributes, CSRF, backend session lifecycle
Browser SPAPublic client, cannot securely embed secretsXSS, refresh coordination, recovery after page reload
Native AppPublic client, uses system authorization interaction and PKCERedirect reception, system storage, process lifecycle
Backend ServiceUse client credentials to express service identity when applicableCredential protection, scope, rotation, and workload ownership

client credentials have no end-user login step; do not use a specific employee's identity to interpret its sub. In the base specification, this flow should not issue refresh tokens; services usually re-acquire access tokens using their own credentials. Application embedded secrets, OAuth authorization, and user authentication are three separate problems that need to be designed independently. RFC 6749 §4.4

When troubleshooting integration, first locate the stage: if the authorization request is rejected, check registration and parameters; for invalid_grant, check if the code is expired, used, or binding mismatch; for signature verification failure, check trusted issuer and keys; if login succeeds but API is rejected, check token type, audience, scope, and resource permissions. Do not use "can decode JWT" as the criterion for completion of login integration.