Configuring Short-Lived JWT Sessions
A browser session built on a short-lived access token plus a silent refresh gives you a small compromise window without logging users out every fifteen minutes. This how-to configures the portal to issue a 15-minute access token, refresh it silently before expiry, and store it in a hardened cookie — the browser-session implementation behind Session & Token Management within Authentication, RBAC & Security Governance.
Prerequisites
- Backstage 1.20+ (or a portal backend with an OIDC session provider) already integrated with your identity provider per OIDC & SSO Configuration.
- An identity provider that issues both access and refresh tokens and supports silent renewal.
- A
${SESSION_SECRET}for signing the portal’s own session cookie, injected at runtime. - HTTPS everywhere, because
Securecookies and refresh flows assume it.
Exact Configuration
-
Set a short access-token lifetime and enable refresh in the auth provider config.
# app-config.yaml # Requires Backstage >= 1.20.0 auth: session: secret: ${SESSION_SECRET} providers: oidc: production: metadataUrl: ${OIDC_METADATA_URL} clientId: ${OIDC_CLIENT_ID} clientSecret: ${OIDC_CLIENT_SECRET} prompt: auto # allow silent renewal without a UI prompt additionalScopes: offline_access # request a refresh token -
Harden the session cookie. Set
Secure,HttpOnly, andSameSite=Laxso the token is not readable by JavaScript and is not sent on cross-site requests.// packages/backend/src/sessionCookie.ts // Requires Node.js >= 20 export const cookieOptions = { httpOnly: true, secure: true, sameSite: 'lax' as const, maxAge: 15 * 60 * 1000, // 15 minutes, matched to the access token }; -
Refresh silently before expiry. Schedule a renewal at ~80% of the token lifetime so a request never races an expired token.
// packages/app/src/auth/scheduleRefresh.ts // Requires @backstage/core-plugin-api >= 1.9.0 export function scheduleRefresh(expiresInSec: number, refresh: () => void) { const renewAtMs = expiresInSec * 1000 * 0.8; setTimeout(refresh, renewAtMs); }
Validation
# Requires curl, jq
# 1. The access token's lifetime is short
echo "${ACCESS_TOKEN}" | cut -d. -f2 | base64 -d 2>/dev/null \
| jq '(.exp - .iat)'
# Expected: 900 (15 minutes) or your configured value
# 2. The session cookie carries hardening flags
curl -sI "${PORTAL_URL}/api/auth/oidc/refresh" | grep -i set-cookie
# Expected: HttpOnly; Secure; SameSite=Lax
# 3. A refresh returns a new token without a login prompt
curl -s "${PORTAL_URL}/api/auth/oidc/refresh" -H "Cookie: ${SESSION_COOKIE}" | jq '.expiresInSeconds'
# Expected: a fresh lifetime, no redirect to login
Edge Cases & Troubleshooting
| Symptom | Root Cause | Resolution |
|---|---|---|
| Users logged out every 15 minutes | Refresh not requested or offline_access scope missing |
Add offline_access and confirm the issuer returns a refresh token |
| Session survives far too long | Cookie maxAge far exceeds the token lifetime |
Match cookie maxAge to the access-token TTL |
| Token readable in browser storage | Stored in localStorage instead of an HttpOnly cookie |
Move the session to an HttpOnly, Secure cookie |
| Refresh loops or fails silently | Clock skew rejecting the just-issued token | Allow a small clock_skew_tolerance on validation |
| Session lost on a subdomain | SameSite/domain scoping too strict |
Set the cookie domain to the portal’s parent domain if subdomains are used |
Frequently Asked Questions
Where should the refresh token be stored?
Server-side, associated with the session, never in the browser. The browser holds only the HttpOnly session cookie; the backend exchanges the refresh token with the identity provider and returns a fresh access token. Keeping the refresh token off the client means a stolen session cookie cannot be used to mint new tokens indefinitely.
Does a short access token hurt the user experience?
Not with silent refresh. The user never sees the token turn over because renewal happens in the background at 80% of the lifetime. The only time they re-authenticate is when the refresh token itself expires or is revoked, which is exactly the behavior you want.