Receiving the response
At the end of stage 2, the authenticator sends the user's browser back to your callback URL with the signed response. To decrypt and verify the response, your server also needs the challenge that it stored in stage 1. The challenge is usually in the user's session, so the session cookie must arrive together with the response.
The request with authentication response comes from the authenticator's site. Browsers hold back some cookies on requests from other sites, so the session cookie does not always arrive. The selected callback method decides how the response travels, and with it, which of your cookies come along. Every sign-in protocol that sends a result back through the browser has the same problem, including OAuth, OpenID Connect, and SAML.
Which method to choose
In most cases you do not need to change the callback method used. This will default to GET. It works with the session cookie of most frameworks, and it needs the least code.
For a single-page app, for a session cookie marked SameSite=Strict, or to keep responses out of your server logs, use HASH and add a small receiver page.
Use POST to keep the response out of URLs without a script on your page. You then need to pass the response on to a GET, or keep the challenge in a separate cookie marked SameSite=None. Marking your session cookie SameSite=None also works, but we do not recommend it.
| GET | HASH * | POST | |
|---|---|---|---|
Brings a None session cookie | Yes | Yes | Yes |
Brings a Lax session cookie, the usual default | Yes | Yes | No † |
Brings a Strict session cookie | No | Yes | No |
| Keeps the response out of server logs | No | Yes | Yes |
| Needs a script on your callback page | No | Yes | No |
* - when used with a receiver page
† - unless your server passes the response on to a GET
GET: the response in the query string
GET is the default. You can also explicitly ask for it with ext: {callbackMethod: 'GET'}. The browser opens your callback URL with a response parameter in the query string. A cross-site GET brings Lax cookies, so the session with the challenge arrives as well. Verify the response, and end with a redirect to a page without it.
The response is then part of a URL, and servers, proxies, and error trackers often log URLs. To ensure privacy responses are encrypted and the key stored with challenge decrypts them. Long responses, such as attestations from several providers, can also exceed the URL limit of some servers.
OAuth and OpenID Connect return their result in the same way. The provider redirects back with a GET, and puts a code and the state in the query string. The code is a short reference that the app exchanges for tokens in a direct request to the provider. The authenticator runs in the browser and has no server to exchange anything with, so it sends the full signed response.
HASH: the response in the fragment, passed on by your page
Ask for HASH with ext: {callbackMethod: 'HASH'}, and serve a receiver page at your callback URL. The browser opens that page with the response in the fragment, the part of the URL after #. Browsers do not send the fragment to any server. A short script on the page reads the response and sends it to your server, with a form POST or with fetch. That request comes from your own page, so it brings every cookie, Strict ones included.
A receiver page can be this short. Serve it for a GET of your callback URL:
html
<!doctype html>
<form method="post" action="/callback">
<input type="hidden" name="response">
</form>
<script>
const params = new URLSearchParams(location.hash.split('?')[1]);
document.forms[0].response.value = params.get('response');
document.forms[0].submit();
</script>The script copies the response into the form and posts it to /callback on your own site. There, your server verifies the response with the challenge from the session, as it would for a POST callback. The script submits the form while the page is still loading, so the browser replaces the receiver page in its history. The Back button then skips it and does not send the response again.
The response stays out of server logs, and no server URL limit applies. In exchange, the receiver page needs JavaScript. If your Content Security Policy blocks inline scripts, move the script into a file on your site. A single-page app can do the same in its own code, without a separate page.
Passkeys work in a similar way. The browser hands the signature to a script on your page, and the script sends it to your server. OAuth's implicit flow also returned its result in the fragment. It is discouraged now, because that result was an access token, which works for anyone who copies it. A triauth sign-in response works only once, and only with the challenge in your session, which also holds the key that decrypts it.
POST: the response in a form
Ask for POST with ext: {callbackMethod: 'POST'}. The authenticator submits a form to your callback URL with a response field. The response travels in the body of the request, so it stays out of URLs, logs, and the browser history.
A cross-site POST brings neither Lax nor Strict cookies, so the session with the challenge does not arrive. You can receive the response in one of two ways.
The first way passes the response on to a GET. Store it in a short-lived cookie marked SameSite=Lax; Secure; HttpOnly, and answer with a 303 redirect to your callback URL. The browser follows the redirect with a GET, which brings Lax cookies. Your session cookie and the new cookie now arrive together. Read the challenge from the session and the response from the cookie, delete the cookie, and verify the response as for a GET callback.
For the first way, handle the POST outside your session middleware. A framework that starts a session on every request answers the POST with a new session cookie. That cookie replaces the user's session. The challenge is then gone, and a user who is already signed in is signed out. Express with express-session does this in its default setup.
The second way keeps the challenge in a separate cookie marked SameSite=None; Secure; HttpOnly, which a cross-site POST brings. Sign the cookie with your server's key, as most frameworks can do, so that the browser cannot change the challenge. At the callback, read the challenge from the cookie, delete the cookie, and start a new session for the user. A Lax session cookie that the callback sets arrives on the page that it redirects to.
You can also mark your session cookie itself SameSite=None; Secure. The session with the challenge then arrives with the POST, and nothing else changes. We do not recommend it. Browsers then send your session cookie also with requests that other sites start, to every route of your app, so you lose the CSRF protection that Lax gives.
OpenID Connect with its form post response mode, and SAML with its HTTP-POST binding, deliver the result in the same way. Chrome's switch to Lax by default in 2020 broke such sign-ins. Their libraries, such as ASP.NET Core and the Shibboleth service provider, now mark the cookies that hold the sign-in state SameSite=None; Secure.
Why a key in the URL is not enough
Your server could also find the challenge by a random key in the query string of the callback URL, with no cookie. Do not rely on that alone, because anyone can copy a URL. An attacker could start a sign-in on your website with their own identifier, approve it on their own device, and send the link with the response to a victim. Your website would then sign the victim in to the attacker's account, and anything the victim saves there would go to the attacker.
The session cookie ties the challenge to the browser that started the sign-in, and this tie stops the attack. The attack is known as login CSRF. OAuth defends against it in the same way, and ties its state parameter to the browser, usually with a cookie.
Before you rely on a callback method
- End the callback with a redirect to a page without the response, also when verification fails. A reload then cannot send the response again.
- The CSRF protection built into many frameworks can reject the POST that delivers the response. Exempt that route from it. The challenge already ties the response to the browser, as a CSRF token would. If your framework cannot exempt a single route from its check of cross-site posts, use GET or HASH.