Sessions & token refresh
How long tokens live, how to refresh them safely, and how sessions end.
Every sign-in creates a session. The session holds a short-lived access token that you send with each request, and a refresh token that you exchange for new tokens. This page covers what your frontend has to handle so users stay signed in and a stolen token stops working.
Lifetimes
| Lifetime | What happens then | |
|---|---|---|
| Access token | 15 minutes | Requests answer AUTH_TOKEN_EXPIRED. Refresh and retry. |
| Refresh token (idle) | 30 days since the last refresh | The session ends. Each refresh restarts this window. |
| Session (absolute) | 90 days since sign-in | The session ends however active it is. The user signs in again. |
Signing out takes effect immediately and doesn't wait for a token to expire. Every request checks that its session is still live.
Refresh tokens are single-use
Exchanging a refresh token gives you a new access token and a new refresh token. The one you sent stops working, so always store both tokens from the response.
curl -X POST "$AUTHORIC_URL/$APP_UID/auth/token" \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "<refresh token>" }'Sending a refresh token that has already been exchanged ends the whole session with
SESSION_ENDED and reason reused. A spent token that comes back usually means someone else
copied it, and there's no way to tell the thief from the owner, so neither keeps the session.
The narrow exception
For 30 seconds after an exchange, the refresh token it replaced still returns the current pair instead of ending the session. This covers two browser tabs refreshing at the same moment, or a retry whose first response was lost. It's a safety net, so coordinate your refreshes anyway (see below).
The refresh request doesn't need an Authorization header, and any header you send is ignored.
The access token it would carry is usually the expired one you're replacing.
Errors to branch on
Read error.code, not the message.
| Code | Status | What to do |
|---|---|---|
AUTH_TOKEN_EXPIRED | 401 | Refresh, then retry the request once. |
AUTH_TOKEN_INVALID | 401 | Refresh, then retry the request once. |
SESSION_ENDED | 401 | Don't refresh. Sign the user out and show the sign-in screen. error.details.reason says why: expired, revoked, reused, signed_out, invalid or legacy. |
Any other 401 isn't about the user's session. For example, an external service might reject a key your app supplied. Show it as an ordinary error, and don't sign anyone out over it.
A refresh strategy that holds up
- Refresh early. Read the
expclaim from the access token and refresh about a minute before it passes. Useexpfor timing only, never for trust. - Refresh on
AUTH_TOKEN_EXPIRED/AUTH_TOKEN_INVALIDas well, and retry the refused request once. - Run one refresh at a time, across every tab of your app. Because refresh tokens are single-use, two tabs exchanging the same token at once is a replay.
- Keep the session on network errors. Being offline, a timeout or a 5xx doesn't mean the
session is over. Back off and retry. Only
SESSION_ENDEDends it. - Store the pair you got back before doing anything else with it.
In a browser, a Web Lock handles point 3. Inside the lock, check whether another tab already refreshed before you spend your token:
async function refreshSession(staleAccessToken: string) {
return navigator.locks.request("my-app-auth-refresh", async () => {
const current = loadTokens();
// Another tab refreshed while we waited: use its pair, spend nothing.
if (current.accessToken !== staleAccessToken) return current;
const response = await fetch(`${API}/${APP}/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: current.refreshToken }),
});
if (response.status === 401) {
clearTokens();
throw new Error("session ended"); // send the user to sign in
}
if (!response.ok) throw new Error("refresh failed"); // keep the session, retry later
const next = await response.json();
saveTokens(next); // other tabs see this through the `storage` event
return next;
});
}If your tabs share tokens through localStorage, listen for the storage event so every tab
picks up a new pair, or a sign-out, as soon as one tab writes it.
Where to keep the tokens
- A server component of your own (recommended when you have one). It holds the refresh
token in an
httpOnly,Secure,SameSitecookie and refreshes on the browser's behalf. The browser only ever holds the short-lived access token, in memory. - Browser only. Keep the access token in memory and the refresh token in storage. Pair this with a strict Content Security Policy, because script injection anywhere in your app can read storage.
Never put the refresh token in a URL, and never log it.
Signing out
Sign-out ends the session everywhere it's used: its refresh token and every access token issued from it stop working. Send the refresh token in the body. Unlike the access token, it still works after the access token has expired.
curl -X DELETE "$AUTHORIC_URL/$APP_UID/auth/logout" \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "<refresh token>" }'Signing out is idempotent: ending a session that has already ended still returns 200.
Where a user is signed in
A signed-in user can list their own sessions and end any of the others. The session making the
request is marked current. To end that one, sign out instead.
# List
curl "$AUTHORIC_URL/$APP_UID/auth/sessions?limit=25&offset=0" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Sign out one other session
curl -X DELETE "$AUTHORIC_URL/$APP_UID/auth/sessions/<session uid>" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Sign out everywhere else
curl -X DELETE "$AUTHORIC_URL/$APP_UID/auth/sessions" \
-H "Authorization: Bearer $ACCESS_TOKEN"Each entry has a coarse label (such as Chrome on macOS), how the session began, its IP
address, and when it was last used. These routes are part of the Manage my own account
capability in your policies.
When sessions end without the user signing out
- Password reset ends every session the account has.
- Turning on two-factor authentication (confirming a factor) ends every other session. So does removing the last factor.
- A replayed refresh token ends that session (
reused). - Deleting the account ends its sessions on their next request.
Browsers remembered for two-factor authentication
A user who ticks "remember this device" when entering a code skips the code on that browser until the device expires. Signing out on that browser doesn't change this, and they still need their password (or another sign-in method) every time. The actions a user takes when they suspect someone else has their password do forget remembered browsers:
- Signing out one session from the session list forgets the browser that session used.
- Signing out everywhere else forgets every remembered browser except the current one.
- Resetting the password forgets all of them.
Switching organizations
A user who belongs to several organizations switches by refreshing with activeOrgUid. The
server checks membership before issuing tokens for the new organization. A switch is a
refresh, so it spends the refresh token like any other and belongs inside the same lock.
{ "refreshToken": "<refresh token>", "activeOrgUid": "<organization uid>" }Realtime subscriptions
A subscription stream is authenticated once, when it opens. When the access token it opened
with expires, the server sends a reauth event and closes the stream. Reconnect with your
current access token (refreshing first if needed) and pass the last event id to resume. See
Realtime.
Don't verify tokens yourself
Access tokens are signed with a key only Authoric holds, and there's no public key to check
them against. Treat them as opaque strings: send them, and read exp for timing. Authoric
verifies every request. Your backend should forward the token and act on the response, not
decode it and trust its contents.
When Authoric rotates its signing keys, tokens signed with the previous key keep working until
they expire, so users notice nothing. If a key ever has to be withdrawn immediately, requests
answer AUTH_TOKEN_INVALID, the refresh answers SESSION_ENDED, and users sign in again. A
client that follows the rules above handles both cases without special code.