Refreshing OIDC Tokens in Long-Running GIS Sessions

This guide keeps long geospatial work authenticated across token expiry: an hour of digitising with no page load, a multi-gigabyte upload that outlives its access token, and a scheduled export that runs while nobody is present. It sits under Keycloak vs Dex for OIDC Federation, within the Core Portal Architecture & Security Boundaries framework.

Prerequisites

  • A working OIDC integration, as set up in configuring a Keycloak realm for GeoNode SSO.
  • Rights to change token lifetimes and client settings in the identity provider.
  • A gateway that validates tokens per request and can return a distinguishable status on expiry.
  • A test account and a browser session you can leave idle for the length of a token lifetime.

Three session shapes, three different answers

The mistake that produces most of the pain here is treating “keep the user logged in” as one problem. It is three, and only one of them is solved by refreshing a token.

Which mechanism keeps each kind of long session alive Interactive editing, a single long request, and unattended scheduled work, each with the mechanism that keeps it authenticated and the mechanism that cannot. Interactive editing many short requests over an hour nothing in flight at expiry solved by silent refresh between requests must not redirect to a login page One long request a large upload or export crosses expiry mid-transfer solved by a fresh token taken at the start, or a resumable, chunked protocol refresh cannot help mid-request Unattended work a scheduled export or harvest nobody is present to log in solved by a service identity of its own must not store a person's refresh token

The third column is the one that gets designed wrong in a hurry. Storing a user’s refresh token so a nightly job can act as them is expedient and creates a credential that outlives their employment, cannot be attributed at audit, and grants everything that person can do rather than what the job needs.

Step-by-step implementation

1. Set lifetimes from how the portal is used

# Access token: short, because it travels with every request and appears in logs.
kcadm.sh update realms/geoportal -s accessTokenLifespan=300

# Refresh token / SSO session idle: longer than a working session with a gap for
# lunch, so an editor is never bounced mid-task.
kcadm.sh update realms/geoportal \
  -s ssoSessionIdleTimeout=43200 \
  -s ssoSessionMaxLifespan=57600

# Rotate refresh tokens on use, and revoke the family if an old one is replayed.
kcadm.sh update realms/geoportal \
  -s revokeRefreshToken=true \
  -s refreshTokenMaxReuse=0

2. Refresh silently, before expiry, and only once

The client must refresh proactively rather than after a rejection, and it must ensure that a burst of parallel tile requests produces one refresh rather than thirty.

// auth.js — a single in-flight refresh, shared by every pending request.
let refreshing = null;

async function accessToken() {
  const tok = readToken();
  // Refresh at 75% of the lifetime, not at expiry: clocks drift and requests queue.
  if (tok && tok.expiresAt - Date.now() > tok.lifetimeMs * 0.25) return tok.value;

  // Collapse concurrent refreshes into one — a viewport of tiles must not
  // trigger a refresh per tile, which is how a rotating refresh token gets
  // replayed and the whole family revoked.
  refreshing = refreshing || doRefresh().finally(() => { refreshing = null; });
  return (await refreshing).value;
}

async function doRefresh() {
  const res = await fetch("/auth/refresh", { method: "POST", credentials: "include" });
  if (!res.ok) {
    // Genuine expiry: preserve unsaved work before any navigation happens.
    window.dispatchEvent(new CustomEvent("session-expiring"));
    throw new Error("refresh failed");
  }
  return storeToken(await res.json());
}

3. Never lose the editor’s geometry

An expiry that discards unsaved edits is remembered long after the authentication detail is forgotten. Treat the expiry event as a UI concern first.

// editor.js — on the warning, save a local draft and prompt in place.
window.addEventListener("session-expiring", async () => {
  const draft = editorState.serialiseUnsaved();          // GeoJSON, in local storage
  localStorage.setItem("draft:" + location.pathname, JSON.stringify(draft));
  showInlineReauthPrompt();       // an inline dialog, never a full-page redirect
});

4. Give unattended jobs their own identity

# A confidential client with the client-credentials grant: no user, no refresh
# token, and permissions scoped to what the job actually does.
kcadm.sh create clients -r geoportal \
  -s clientId=nightly-export \
  -s serviceAccountsEnabled=true \
  -s standardFlowEnabled=false \
  -s publicClient=false

# Grant only the roles the export needs — not the roles of the person who
# happened to request the export.
kcadm.sh add-roles -r geoportal --uusername service-account-nightly-export \
  --rolename export_reader

What a rotating refresh token does to a tiled client

Refresh-token rotation is a real security improvement and it interacts badly with the request pattern a map produces, if the client is naive about it.

Parallel requests and a rotating refresh token A naive client triggering many simultaneous refreshes and being logged out, beside a client that collapses them into a single refresh. Naive client — one refresh per pending request the same refresh token presented four times provider sees a replay, revokes the token family — the user is logged out mid-edit Client that collapses concurrent refreshes one refresh; the other requests await its result new token issued, every pending request proceeds

Validate locally, not with a call per request

How the gateway checks a token decides what an identity-provider outage does to the portal. Local validation against cached signing keys keeps existing sessions working when the provider is unreachable; introspection asks the provider on every request and turns its outage into yours.

Local validation versus introspection Two approaches compared by cost per request, behaviour during a provider outage, and revocation visibility. local validation signature checked against cached keys microseconds per request sessions survive a provider outage revocation visible only at expiry — which is why tokens are short introspection per request the provider answers every call a network round trip per request a provider outage is a portal outage revocation is immediate — the one real advantage

Immediate revocation matters for a small number of high-value operations rather than for tile traffic. A workable split is local validation everywhere, with introspection reserved for write operations and administrative actions, so the expensive check is paid only where its guarantee is worth the dependency.

One further consideration applies to portals that broker several upstream identity providers. The effective session length is the shortest of the chain — the portal’s own session, the broker’s, and the upstream directory’s — and the one that ends first is usually the one nobody configured. Check all three when a user reports being logged out earlier than the settings suggest, and record which of them is authoritative for this portal so the next investigation starts in the right place.

Verification

# 1. An access token expires when configured to
TOKEN=$(curl -s -d "client_id=geonode" -d "username=$TEST_USER" -d "password=$TEST_PASS" \
  -d "grant_type=password" "$ISSUER/protocol/openid-connect/token" | jq -r .access_token)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.exp - .iat'
#   expect: 300

# 2. A refresh returns a NEW refresh token (rotation is on)
R1=$(curl -s -d "client_id=geonode" -d "grant_type=refresh_token" -d "refresh_token=$REFRESH" \
  "$ISSUER/protocol/openid-connect/token" | jq -r .refresh_token)
test "$R1" != "$REFRESH" && echo "rotating"

# 3. Replaying the old refresh token is refused
curl -s -o /dev/null -w '%{http_code}\n' -d "client_id=geonode" -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH" "$ISSUER/protocol/openid-connect/token"
#   expect: 400 — and the family is revoked, which is the intended behaviour

# 4. The service account can obtain a token with no user involved
curl -s -d "client_id=nightly-export" -d "client_secret=$EXPORT_SECRET" \
  -d "grant_type=client_credentials" "$ISSUER/protocol/openid-connect/token" | jq -r .access_token | cut -c1-12
#   expect: a token, and its roles limited to export_reader

# 5. An idle browser session survives a full access-token lifetime
#    Leave the editor idle for 6 minutes, then draw a feature.
#    expect: the edit saves, with no redirect and no lost geometry

Troubleshooting matrix

Symptom Likely cause Fix
Users are logged out during heavy map use Parallel requests each triggering a refresh, tripping replay detection Collapse concurrent refreshes into one shared promise
An upload fails at a consistent duration The access token expires mid-request Take a fresh token immediately before starting, or chunk the upload
A nightly job stopped after a colleague left It used that person’s refresh token Move it to a service identity with its own scoped roles
Editors lose unsaved geometry on expiry Expiry handled as a page redirect Save a local draft on the warning, and re-authenticate in place
Refresh succeeds but the API still returns 401 The gateway caches the old token’s validation result Key the validation cache on the token, and respect its expiry
Sessions end abruptly at a fixed time of day The maximum session lifespan, not the idle timeout Raise the maximum, or accept it and warn the user before it arrives
Refresh fails only for users on one identity provider Clock skew between the broker and that upstream Synchronise clocks and allow a small leeway in validation

FAQ

How short can an access token be without causing problems?

Minutes is comfortable for a portal whose clients refresh properly; the practical floor is set by how much refresh traffic the identity provider will see, since every client refreshing every five minutes is a real load. Five minutes is a reasonable default, and the number matters far less than whether the client refreshes proactively.

Should refresh tokens be stored in the browser?

Not in a place JavaScript can read. Keep the refresh token in an HTTP-only, same-site cookie handled by a small server-side endpoint, and let the browser hold only the short-lived access token. That way a script injection cannot exfiltrate the long-lived credential.

What about desktop GIS clients that only understand basic authentication?

Give them a scoped credential of their own rather than trying to make them speak OIDC — an API key as described in issuing scoped API keys for OGC clients is usually the pragmatic answer, with the scope narrowed to the layers that client needs.

Is refresh-token rotation worth the extra client complexity?

Yes, provided the client collapses concurrent refreshes. Rotation turns a stolen refresh token into a detectable event rather than a silent long-lived compromise, and the client-side change is a few lines. Without the collapsing, rotation will produce mysterious logouts under load and will be turned off — which is the worst of both outcomes.

How should the portal behave when the identity provider is unreachable?

Existing sessions with unexpired access tokens should continue working, which they will if validation uses cached signing keys rather than an introspection call per request. New logins and refreshes will fail, and that is correct. Choosing local validation over introspection is what turns an identity outage into a degraded service rather than a total one.

Up one level: Keycloak vs Dex for OIDC Federation.