OAuth Provider Request Signing#
The @better-auth/oauth-provider plugin uses HMAC-SHA-256 to sign OAuth authorization query parameters before redirecting to login, consent, or post-login pages. This prevents tampering with the query string as it transits through CDNs, reverse proxies, or the client browser.
Why It Exists#
During an OAuth authorization flow, when a user is not yet authenticated, the server redirects to the login page with the original authorization parameters (e.g., client_id, scope, redirect_uri) embedded in the URL. Those parameters must survive the round-trip intact and unmodified. Without signing, an attacker could alter them in transit.
An additional failure mode — exposed by real-world deployments on Vercel's edge and similar CDNs — is that proxies can reorder query parameters in Location headers. A naïve HMAC over URLSearchParams.toString() (which preserves insertion order) fails verification whenever a proxy reorders the string.
Core Implementation#
1. Canonicalization (signed-query.ts)#
All signing and verification routes through canonicalizeOAuthQueryParams, which sorts URLSearchParams entries lexicographically by key, then by value. This produces a stable, order-independent canonical form before any HMAC is computed.
sort entries by (key ASC, value ASC)
→ rebuild URLSearchParams
→ call .toString() as the HMAC message
This approach mirrors AWS Signature V4 canonical query string sorting.
2. Signing (authorize.ts → signParams)#
signParams runs when the server needs to bounce the user to a UI page (login, consent, etc.) and must protect the authorization request:
- Serializes the current
OAuthAuthorizationQueryintoURLSearchParams. - Sets
exp(Unix expiration) andba_iat(issued-at, millisecond epoch) viasignedQueryIssuedAtParam. - Deletes any attacker-supplied
sig(prevents signature injection). - Sets the optional
postLoginClearedParam(ba_pl) for consent redirects that have cleared the post-login gate. - Calls
setSignedOAuthQueryParameterNames— writes the full sorted list of included parameter names into theba_parammulti-value key. This lets the client reconstruct the exact signed parameter set after a redirect, without forwarding unsigned page-specific params. - Calls
makeSignature(canonicalizeOAuthQueryParams(params).toString(), ctx.context.secret)— the HMAC is computed over the canonical form, never raw insertion order. - Appends the resulting
sigvalue to the params.
The HMAC primitive is makeSignature from better-auth/crypto, which uses the Web Crypto API (HMAC / SHA-256) and returns a base-64 encoded 44-character signature.
3. Verification (utils/index.ts → verifyOAuthQueryParams)#
verifyOAuthQueryParams(oauth_query, secret):
- Parses the query string into
URLSearchParams. - Extracts and removes
sig(rejecting if more than onesigis present — duplicate sigs are an attack vector). - Re-canonicalizes the remaining params via
canonicalizeOAuthQueryParams. - Recomputes the HMAC and compares with
constantTimeEqualto prevent timing attacks. - Checks that
exphas not passed.
4. Client-side reconstruction (client.ts → buildSignedOAuthQuery)#
After the user completes login/consent and the browser returns to the OAuth authorize endpoint, the client must reconstruct oauth_query from the URL. buildSignedOAuthQuery reads the ba_param list, retains only those parameters (plus sig and ba_param itself), and discards unrelated page params. Redirects that lack a ba_param declaration are rejected entirely — the new contract is fail-closed.
Special Parameters#
| Parameter | Constant | Purpose |
|---|---|---|
sig | — | HMAC-SHA-256 signature |
exp | — | Unix expiry (seconds) |
ba_iat | signedQueryIssuedAtParam | Issued-at (milliseconds) for client-side staleness checks |
ba_param | signedQueryParameterNameParam | Sorted list of signed parameter names |
ba_pl | postLoginClearedParam | Session ID proving post-login gate was cleared |
Key Source Files#
| File | Role |
|---|---|
signed-query.ts | Canonicalization, param-name manifest, issued-at helpers |
authorize.ts | signParams — signs during login/consent redirects |
utils/index.ts | verifyOAuthQueryParams — verifies on return |
crypto/index.ts | makeSignature — HMAC-SHA-256 primitive |
History#
- PR #9088 — First fix: added
params.sort()before signing/verification to address CDN reordering on Vercel. Closed (superseded). - PR #9941 — Final fix: replaced bare sort with the full canonicalization module (
signed-query.ts), addedba_parammanifest for safe client reconstruction, and hardened against duplicatesigattacks.