OAuth 2.x & OpenID Connect
Delegated access and 'Sign in with…' — done with PKCE, short-lived tokens and the right scopes.
In 30 seconds
OAuth lets an app act on a user's behalf without ever seeing their password: the user approves access on the authorisation server, and the app receives a limited, expiring access token. OpenID Connect adds identity on top, so the app also learns who the user is — that is what powers 'Sign in with Google' or Apple. The modern recipe is the authorisation code flow with PKCE for every client.
Why I use it
- I use OAuth because almost every API I build serves several clients — web, mobile, admin — and tokens with scopes and expiry are the cleanest way to authenticate them without passing passwords around.
- I use social sign-in (Google, Apple, LinkedIn, Facebook) on consumer products because it removes registration friction, and OpenID Connect is the standard that makes it safe when verified correctly.
- I care about the details — PKCE, token lifetimes, revocation, scopes — because authentication bugs are the ones that leak accounts, and they are cheap to prevent at design time.
- Knowing the specs lets me choose wisely between Laravel Passport, Sanctum, a managed identity provider or custom JWTs for each project instead of defaulting to one tool.
How it works
01The idea: a valet key, not your house key
Some cars come with a valet key: it starts the engine but cannot open the trunk. OAuth gives apps valet keys. Four roles are involved. The resource owner is the user. The client is the app that wants access. The authorisation server authenticates the user and issues tokens. The resource server is the API that accepts tokens. The user logs in only at the authorisation server and approves specific scopes — 'read your calendar' — and the client gets an access token valid for those scopes and a limited time. The client never sees the password. Important distinction: OAuth 2.0 is about authorisation (what the app may do), not authentication (who the user is). Using a plain OAuth access token as proof of identity is a classic mistake; that gap is exactly what OpenID Connect fills.
- Resource owner: the user
- Client: web, mobile or server app
- Authorisation server: logs the user in, issues tokens
- Resource server: your API, validates tokens
02Authorisation code flow with PKCE, step by step
This is the recommended flow for web apps, SPAs and mobile apps. The client creates a random code_verifier and sends its SHA-256 hash (code_challenge) in the authorisation request. The user logs in and consents, and the server redirects back with a one-time authorisation code. The client exchanges the code plus the original verifier for tokens over a back channel. PKCE (Proof Key for Code Exchange, RFC 7636) means a stolen code is useless without the verifier, which never left the app. The state parameter protects against CSRF on the redirect, and with OIDC a nonce binds the ID token to this login. Redirect URIs must be registered and matched exactly. OAuth 2.1, still an IETF draft in late 2026 but already reflected in RFC 9700 (OAuth 2.0 Security Best Current Practice), requires PKCE for all clients and drops the implicit and password grants.
# 1) Browser is redirected to the authorisation server
GET https://auth.example.com/authorize?response_type=code
&client_id=web-app
&redirect_uri=https://app.example.com/callback
&scope=openid%20profile%20email%20orders:read
&state=af0ifjsldkj&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
# 2) Back channel: exchange the code for tokens
POST https://auth.example.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://app.example.com/callback
&client_id=web-app&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
03Tokens: access, refresh and ID tokens
An access token is what the client sends to the API, usually as 'Authorization: Bearer ...'. It should be short-lived — minutes, not months — because anyone holding a bearer token can use it. It may be opaque (the API checks it with the authorisation server via introspection) or a JWT (the API verifies the signature locally). A refresh token lets the client get new access tokens without the user logging in again; it is long-lived and powerful, so store it carefully, rotate it on each use and revoke it on logout. An ID token, from OpenID Connect, is a signed JWT that tells the client who logged in: issuer (iss), subject id (sub), audience (aud), expiry (exp), nonce and optionally email or name. The ID token is for the client to read; it is not meant to be sent to your APIs as an access token.
- Access token: for the API, short-lived, scoped
- Refresh token: for the client to renew access, rotate and revoke
- ID token: for the client, proves who logged in
04OpenID Connect: 'Sign in with Google / Apple'
OpenID Connect (OIDC) is a thin identity layer on OAuth 2.0. You add the 'openid' scope, and the authorisation server returns an ID token alongside the access token. Providers publish a discovery document at /.well-known/openid-configuration listing their endpoints and the JWKS URL with signing keys. To sign a user in safely, your backend must verify the ID token: signature with the provider's current key, iss equals the provider, aud equals your client id, exp not passed, and nonce matches the one you sent. Then link the account by the provider's stable sub, not by email, because emails change and are not always verified. On mobile, native SDKs (Google Sign-In, Sign in with Apple) return an ID token or authorisation code, which the app sends to your backend for the same verification before you issue your own session or API tokens.
use Laravel\Socialite\Facades\Socialite;
// Redirect the user to Google
Route::get('/auth/google', fn () => Socialite::driver('google')->redirect());
// Callback: Socialite exchanges the code and fetches the profile
Route::get('/auth/google/callback', function () {
$g = Socialite::driver('google')->user();
$user = User::firstOrCreate(
['google_id' => $g->getId()], // stable provider "sub"
['name' => $g->getName(), 'email' => $g->getEmail()],
);
Auth::login($user);
return redirect('/account');
});
05Other grants and API keys
Not every client has a user in a browser. Client credentials: a backend service authenticates as itself (client id and secret, or better a signed assertion or mutual TLS) to call another service — machine-to-machine. Device authorisation (RFC 8628): smart TVs and CLIs show a code that the user enters on their phone. Token exchange (RFC 8693): a service swaps one token for another with narrower scope or a different audience. Refresh token grant: renew access. Deprecated: the implicit grant (tokens in the URL fragment) and the resource owner password grant (the app collects the user's password), both removed in OAuth 2.1. Simple API keys are still common for server-to-server integrations; if you use them, make them scoped, prefixed by environment (live/test), hashed in storage, rate-limited, and rotatable without downtime.
06Laravel Passport in practice
Laravel Passport is a full OAuth2 server for Laravel APIs, built on the League OAuth2 server. Current versions support the authorisation code grant (with PKCE for public clients), client credentials, device authorisation, refresh tokens and personal access tokens; the password and implicit grants are disabled unless you explicitly enable them. You define scopes with Passport::tokensCan, protect routes with the auth:api guard plus scope middleware, and set token lifetimes in a service provider — by default access tokens live a year, which is far too long for most apps, so shorten them. For first-party SPAs on the same domain, Laravel Sanctum's cookie-based sessions are often simpler than OAuth; Passport shines when you need real OAuth clients, third-party access or token-based mobile apps.
// AppServiceProvider::boot()
use Carbon\CarbonInterval;
use Laravel\Passport\Passport;
Passport::tokensCan([
'orders:read' => 'View your orders',
'orders:create' => 'Place orders',
]);
Passport::tokensExpireIn(CarbonInterval::minutes(30));
Passport::refreshTokensExpireIn(CarbonInterval::days(30));
// routes/api.php
use Laravel\Passport\Http\Middleware\CheckToken;
Route::get('/orders', [OrderController::class, 'index'])
->middleware(['auth:api', CheckToken::using('orders:read')]);
Use it when
- An API consumed by several first-party clients (web, mobile, admin) that need scoped, expiring tokens.
- Letting third-party apps or partners access user data with the user's consent.
- Social or enterprise sign-in (Google, Apple, Microsoft, LinkedIn, corporate SSO) via OpenID Connect.
- Machine-to-machine calls between services using the client credentials grant.
Skip it when
- A single first-party web app on one domain: secure HTTP-only session cookies (e.g. Laravel Sanctum's SPA mode) are simpler and just as safe.
- Internal scripts that only need a narrow, revocable API key, where a full OAuth server adds complexity without benefit.
- When you would be writing your own OAuth server from scratch — use a maintained library or identity provider instead.
Alternatives
- Session cookies
Classic server sessions with HttpOnly, Secure, SameSite cookies; ideal for same-site web apps.
- Laravel Sanctum
Lightweight SPA cookie auth and simple personal API tokens for Laravel, without full OAuth flows.
- Managed identity providers
Auth0, Okta, Microsoft Entra ID, AWS Cognito, Keycloak (self-hosted): OAuth/OIDC, MFA and SSO as a service.
- SAML 2.0
Older XML-based enterprise SSO standard, still required by some corporate customers.
Best practices
- Use the authorisation code flow with PKCE (S256) for every client, including confidential web apps.
- Register exact redirect URIs; never allow wildcards or open redirects.
- Keep access tokens short-lived (minutes), rotate refresh tokens on use, and revoke them on logout and password change.
- Validate JWTs fully: signature with an allow-listed algorithm, iss, aud, exp, nbf — and nonce for ID tokens.
- Design scopes around business capabilities (orders:read) and check them on every endpoint, plus object-level authorisation.
- Link social accounts by the provider's sub claim, not by email alone.
- Store tokens safely: HttpOnly cookies or a backend-for-frontend on the web, Keychain/Keystore on mobile — never localStorage for long-lived tokens.
Common mistakes
- Using an OAuth access token as proof of login without OIDC validation, or accepting ID tokens issued for another app (no aud check).
- Accepting JWTs with alg 'none' or letting the token header choose the algorithm.
- Year-long access tokens with no revocation path.
- Skipping the state parameter, allowing login CSRF on the callback.
- Embedding a client secret in a mobile app or SPA bundle, where anyone can extract it.
Deep dive
07Validating JWTs correctly (and the classic attacks)
A JWT is header.payload.signature, base64url-encoded. Validation must be strict. Pin the expected algorithm in your code (for example RS256 or ES256) and reject anything else — historic vulnerabilities came from libraries trusting the header's alg, allowing 'none' or HS256-with-the-public-key confusion. Fetch signing keys from the issuer's JWKS endpoint, cache them, select by kid, and refresh on an unknown kid to support key rotation. Check iss and aud exactly, exp and nbf with a small clock-skew allowance, and for ID tokens the nonce. Do not put sensitive data in the payload — it is signed, not encrypted. RFC 8725 (JWT Best Current Practices) is the reference. Prefer asymmetric signing so resource servers only hold public keys, and only the authorisation server can mint tokens.
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["RS256"], // never trust the header's alg
clockTolerance: 30, // seconds
});
if (await denylist.has(payload.jti as string)) throw new Error("revoked");
return payload; // sub, scope, exp, ...
}
08Refresh token rotation and reuse detection
Refresh tokens are the crown jewels: long-lived and able to mint access tokens. Rotation means every refresh returns a new refresh token and invalidates the old one. Reuse detection adds the key safety property: if an old, already-used refresh token is presented again, either the legitimate client or an attacker has a stolen copy — so the server revokes the whole token family and forces re-login. Implement this by storing a family id and a used flag per refresh token. Add an absolute lifetime (for example 30–90 days) and an idle timeout, so abandoned sessions die. Handle races in mobile apps where two parallel requests both try to refresh: serialise refreshes on the client, or allow a very short grace window for the previous token. Sender-constrained tokens go further, binding tokens to a client key so a stolen token cannot be replayed elsewhere.
09Sender-constrained tokens: DPoP and mTLS
Bearer tokens work like cash: whoever holds them can spend them. Sender-constrained tokens work like a card with a PIN. With DPoP (RFC 9449), the client generates a key pair, and with every request it sends a short signed proof JWT containing the HTTP method, URL, a timestamp and a hash of the access token. The authorisation server binds the token to the client's public key (the cnf claim), and the API checks that each request is signed by the same key. A token leaked through logs or a proxy becomes useless. Mutual TLS (RFC 8705) achieves the same by binding tokens to a client certificate, common in banking and open-finance profiles such as FAPI 2.0. Pushed Authorization Requests (PAR, RFC 9126) complement this by sending authorisation parameters over a back channel instead of the browser URL, preventing tampering.
POST /orders HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsImtpZCI6IjEifQ...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Li4ufX0...
# DPoP proof payload (signed with the client's private key):
# { "htm": "POST", "htu": "https://api.example.com/orders",
# "iat": 1793000000, "jti": "e1j3V_bKic8-LAEB", "ath": "<sha256 of access token>" }
10Multi-tenant and B2B identity design
In SaaS, identity gets more complex: users belong to organisations, may belong to several, and enterprise customers want their own SSO. Put the tenant in the token deliberately — a tenant or org claim chosen at login or via an organisation switcher — and verify on every request that the resource belongs to that tenant; never take tenant ids from request bodies alone. Keep roles and permissions in your application, mapped from IdP groups if needed, rather than trusting arbitrary claims from external IdPs. Support per-tenant OIDC or SAML connections for enterprise SSO, plus SCIM for user provisioning if customers ask. Plan for impersonation: support staff should get a separate, time-boxed token with an act (actor) claim, a mandatory reason and full audit logging, never the customer's own credentials.
Real-world scenarios
Adding Sign in with Apple to a mobile app
Problem
A React Native app already has email and password login. The team must add Sign in with Apple and Google, and wants accounts linked correctly when the same person uses several methods.
How to solve it
Use the native SDKs on the device to get an ID token or authorisation code, then send it to the backend. The backend verifies the ID token signature against the provider's JWKS, plus iss, aud equal to your app's client id, exp and nonce. Store a separate identities table (provider, sub, user_id) and link by sub. Apple may hide the real email behind a relay address and sends the name only on first sign-in, so save it then. Link to an existing account by email only when the provider marks it verified and the user confirms. Finally, issue your own short-lived API token plus a refresh token.
A leaked access token in logs
Problem
A security review finds full Authorization headers in application logs. Access tokens are valid for one year, and some logs were shared with a vendor.
How to solve it
Treat it as an incident. Revoke all access and refresh tokens issued before the fix, forcing re-login, and rotate any client secrets that may also have leaked. Remove the headers from logs: add redaction in the logging middleware and in proxies, and purge old log data where possible. Shorten access token lifetime to 15–30 minutes and enable refresh token rotation. Add a denylist check by jti for emergency revocation. Add a test that fails if Authorization headers appear in log output, and document the token lifetime policy.
Partner API access
Problem
Partners want to create orders through your API from their own servers. Some partners integrate on behalf of many merchants; others only need read access to reports.
How to solve it
Offer the client credentials grant, or scoped API keys if partners need simplicity. Each partner gets separate test and live credentials, with scopes such as orders:create or reports:read, and per-client rate limits. Bind credentials to the tenant or merchants the partner may act for, and enforce it server-side. Require idempotency keys on order creation so retries never duplicate orders. Support overlapping keys for zero-downtime rotation, log usage per client, and give partners a dashboard to revoke and regenerate. For high-value partners, use signed JWT client assertions instead of shared secrets.
Cheat sheet
- Recommended flow for any app
Authorization code + PKCE (code_challenge_method=S256)- OIDC discovery document
GET https://auth.example.com/.well-known/openid-configuration- Make a PKCE challenge (bash)
printf %s "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '='- Install Passport (Laravel)
php artisan install:api --passport- Create a PKCE (public) client in Passport
php artisan passport:client --public- Short Passport access tokens
Passport::tokensExpireIn(CarbonInterval::minutes(30));- Require a scope on a route
->middleware(['auth:api', CheckToken::using('orders:read')])- Revoke a token (RFC 7009)
POST /revoke token=...&token_type_hint=refresh_token- Claims to check on an ID token
signature, iss, aud, exp, iat, nonce — then link by sub- Machine-to-machine
grant_type=client_credentials&scope=reports:read
In my real projects
- I build Laravel APIs (Laravel 8 to 13) with Passport for token authentication, serving web, mobile and admin clients from one backend.
- I implemented Google and Apple sign-in for web and native mobile apps on a restaurant ordering platform, and Google, LinkedIn and Facebook login via Socialite on an investment portal.
- In a multi-tenant POS SaaS, authentication uses RS256-signed JWTs re-validated on each request with a denylist, 2FA for staff and operators, and device tokens for tills.
- The same POS SaaS issues scoped API keys for partners, with live/test separation, rate limits and rotation, plus time-boxed impersonation that requires a reason.
Questions & answers
Q1What is the difference between OAuth 2.0 and OpenID Connect?
OAuth 2.0 is an authorisation framework: it lets a client obtain an access token to call an API on a user's behalf, within approved scopes. It says nothing standard about who the user is. OpenID Connect is an identity layer on top: requesting the 'openid' scope makes the authorisation server also return an ID token — a signed JWT with the user's identifier, issuer, audience and expiry — plus a userinfo endpoint and discovery metadata. So: OAuth answers 'what may this app do?', OIDC answers 'who just logged in?'. 'Sign in with Google' is OIDC.
Q2What is PKCE and why is it required now?
PKCE, Proof Key for Code Exchange, protects the authorisation code flow against code interception. The client generates a random secret (code_verifier), sends only its SHA-256 hash (code_challenge) with the authorisation request, and later must present the original verifier when exchanging the code for tokens. An attacker who steals the code — through a malicious app registered for the same redirect scheme, logs or a referrer leak — cannot redeem it. It was designed for mobile and SPA clients without secrets, but current best practice (RFC 9700 and the OAuth 2.1 draft) recommends it for all clients, including server-side web apps.
Q3Where should a single-page app store tokens?
The safest pattern is not to store them in the browser at all: a backend-for-frontend (BFF) runs the OAuth flow server-side, keeps the tokens, and gives the browser an HttpOnly, Secure, SameSite session cookie. JavaScript cannot read it, so an XSS bug cannot steal long-lived tokens. If the SPA must hold tokens, keep the access token in memory only, make it short-lived, and use refresh token rotation with strict lifetimes. Avoid localStorage for anything long-lived, because any injected script can read it.
Q4JWT access tokens or opaque tokens?
JWTs are self-contained and signed, so APIs can validate them locally without a network call — fast and good for distributed services. The downside is revocation: a JWT stays valid until it expires unless you add a denylist or check a session version. Opaque tokens are random strings the API validates via introspection or a database lookup, so revocation is instant, at the cost of a lookup per request (cacheable). A common compromise: short-lived JWT access tokens of 5–15 minutes, plus revocable refresh tokens, plus a denylist for urgent cases such as a compromised account.
Q5How do you design scopes?
Scopes describe what a token is allowed to do, so name them after business capabilities: orders:read, orders:create, profile:write — not after database tables or every endpoint. Keep the set small and understandable on a consent screen. Ask only for the scopes a client needs, and check them on every route. Remember that scopes are coarse: a token with orders:read must still pass object-level checks so user A cannot read user B's order. For first-party apps, scopes also help limit damage, for example giving a kiosk or till token far fewer rights than an admin token.
Q6How do you implement logout properly with OAuth?
Logout has several layers. Locally, end the app session and delete stored tokens. On the authorisation server, revoke the refresh token (RFC 7009 revocation endpoint) so it cannot mint new access tokens. Existing JWT access tokens remain valid until they expire, so keep them short-lived or add them to a denylist if immediate cut-off matters. For SSO with OpenID Connect, use RP-initiated logout to end the identity provider session too, and consider back-channel logout so other apps are notified. On password change or a suspected compromise, revoke all of a user's tokens.
Senior-level questions
S1Would you build your own authorisation server or use a managed identity provider?
For most products I would not write an OAuth server from scratch; I would choose between a framework-integrated server such as Laravel Passport, a self-hosted product such as Keycloak, or a managed provider such as Auth0, Okta, Entra ID or Cognito. Framework servers are cheap and keep users in my own database — great for first-party web and mobile clients and simple partner access. Managed providers win when the product needs enterprise SSO per customer, MFA policies, SCIM, compliance attestations and anomaly detection; you pay per user and accept vendor lock-in and their data model. Self-hosted Keycloak sits between: full features without per-user fees, but you own upgrades, scaling and security patches. The deciding factors are customer requirements (B2B SSO is a big one), team capacity and how central identity is to the product.
S2How do you handle authorisation across many microservices?
I separate authentication from authorisation decisions. An edge gateway or each service validates the access token locally — signature, issuer, audience, expiry — using cached JWKS. Tokens carry identity, tenant and coarse scopes, not every permission, because large permission sets bloat tokens and go stale. Fine-grained decisions happen in the service that owns the data, or in a policy engine (OPA, Cedar or a relationship-based system) fed with current data. For service-to-service calls, services use their own client credentials or token exchange to get a token with the right audience, instead of forwarding the user's token everywhere. The trade-offs: self-contained JWTs are fast but revoke slowly, so lifetimes stay short; central policy engines add a network hop but keep rules consistent and auditable.
S3What changes with OAuth 2.1 and current best practice compared with classic OAuth 2.0?
The direction is consolidation of hard-learned lessons; OAuth 2.1 is still a draft, but RFC 9700 already codifies most of it. PKCE becomes mandatory for the authorisation code flow for all clients. The implicit grant and the resource owner password grant are removed. Redirect URIs must match exactly. Bearer tokens must not be sent in URL query strings. Refresh tokens for public clients must be sender-constrained or rotated. Beyond the core, best practice adds sender-constrained tokens (DPoP or mTLS) for high-value APIs, PAR to protect authorisation parameters, short access token lifetimes and issuer identification to prevent mix-up attacks. In practice, migrating usually means turning on PKCE everywhere, removing password-grant logins in mobile apps in favour of the browser-based code flow, and tightening lifetimes and rotation.
Glossary
- Authorisation server
- The service that authenticates users and issues access, refresh and ID tokens.
- Access token
- A short-lived credential a client sends to an API to prove it is allowed to act.
- Refresh token
- A long-lived credential used only to obtain new access tokens without a new login.
- ID token
- An OpenID Connect JWT telling the client who the user is and how they logged in.
- Scope
- A named permission requested by the client and granted by the user, such as orders:read.
- PKCE
- A per-login secret and its hash that stop stolen authorisation codes from being redeemed.
- JWKS
- A published JSON set of public keys used to verify the signatures of tokens.
- Client credentials grant
- An OAuth flow where a service authenticates as itself, with no user involved.
- DPoP
- A mechanism binding tokens to a client's key, so a stolen token cannot be replayed.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.