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 Secure cookies and refresh flows assume it.
Prerequisites for short-lived sessions An OIDC integration, an issuer supporting refresh, a session secret, and HTTPS enable short-lived sessions. OIDC integrated issuer supports refresh session secret HTTPS everywhere Session ready short + seamless
Silent refresh depends on the issuer returning a refresh token; without it, a short access TTL means constant re-logins.

Exact Configuration

  1. 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
    
  2. Harden the session cookie. Set Secure, HttpOnly, and SameSite=Lax so 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
    };
    
  3. 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);
    }
    
Session timeline with silent refresh A 15-minute access token is silently refreshed at 80% of its lifetime, keeping the session continuous. Issue token t=0 Silent refresh t≈12m (80%) Would expire t=15m
Refreshing at 80% of the lifetime leaves a buffer, so no in-flight request ever meets an already-expired token.

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
Session validation checks A short token lifetime, a hardened cookie, and a silent refresh confirm the session config. Short lifetime exp − iat = 900 Hardened cookie HttpOnly · Secure Silent refresh no login prompt
A refresh that returns a fresh token without redirecting to login is the proof that silent renewal is wired correctly.

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
Session pitfalls by class Frequent logouts and refresh loops are refresh issues; long sessions and readable tokens are cookie-hardening issues. Refresh frequent logout refresh loop → offline_access + skew Cookie hardening over-long session token readable → match TTL + HttpOnly
Matching cookie maxAge to the access-token TTL prevents a session that lives long after its token should have expired.

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.