Keycloak Authentication in Astro
Last updated: June 2026
Astro authenticates against Keycloak using the standard OIDC authorization-code flow with PKCE in server (SSR) mode — either via the auth-astro integration as the fast path, or a lightweight hand-rolled flow using openid-client or arctic for full control. Sessions are stored server-side (encrypted cookie or Redis), and a middleware file at src/middleware.ts enforces route protection before any page component runs.
TL;DR: This guide walks through connecting an Astro SSR site to a self-hosted Keycloak 26.x realm using the authorization-code flow with PKCE (RFC 7636). You’ll configure a Keycloak client, build login and callback routes, protect pages via middleware, and implement logout — no third-party auth SaaS required. Estimated time: 45 minutes.
Why Astro’s Static Mode Can’t Hold Sessions
Astro’s default output: 'static' mode renders every page at build time and ships pure HTML to a CDN. There’s nowhere to store a server-side session because no server is running per request. Authentication requires request-scoped state: you need to validate a token on each hit, set a cookie, and redirect unauthenticated users. That demands output: 'server' (or output: 'hybrid' with individual static islands).
Switch your project to on-demand rendering in astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});
Any Node-compatible adapter works — Vercel, Cloudflare Workers, and Netlify all support SSR. The session cookie approach used in this guide is adapter-agnostic.
If you’re curious how session storage tradeoffs compare across distributed deployments, see our deep dive on session management in distributed systems: cookies vs. tokens vs. server-side sessions.
Configuring a Keycloak 26.x Client
Log in to your Keycloak Admin Console and select the realm you’re working with (create one if needed). Then navigate to Clients > Create client.
Client settings that matter:
| Field | Value |
|---|---|
| Client type | OpenID Connect |
| Client ID | astro-app (any slug) |
| Client authentication | Off (public client) |
| Standard flow | Enabled |
| Valid redirect URIs | http://localhost:4321/auth/callback |
| Valid post-logout redirect URIs | http://localhost:4321/ |
| Web origins | http://localhost:4321 |
A public client is the right choice for a browser-facing Astro app that uses PKCE — there’s no client secret to protect on the server side. If you’re running a Node server you fully control and want token introspection or service-to-service calls, flip Client authentication on and treat the secret as a server-only environment variable.
For production, replace localhost:4321 with your domain in all three URI fields.
Save your .env file:
# .env
PUBLIC_KEYCLOAK_URL=https://auth.example.com
PUBLIC_KEYCLOAK_REALM=myrealm
PUBLIC_KEYCLOAK_CLIENT_ID=astro-app
SESSION_SECRET=replace-with-a-32-char-random-string
SESSION_SECRET is used to sign the session cookie. Never expose it in client-side bundles — prefix it without PUBLIC_ so Astro keeps it server-only.
For a broader view of how OIDC tokens work under the hood, see OpenID Connect explained for developers.
The Quick Path: auth-astro Integration
If you want authentication in under ten minutes and don’t need custom session logic, the auth-astro community integration wraps the OAuth flow for you. Install it:
npm install auth-astro @auth/core
Add the integration and configure a Keycloak provider in astro.config.mjs:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import auth from 'auth-astro';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [
auth({
providers: [
{
id: 'keycloak',
name: 'Keycloak',
type: 'oidc',
issuer: `${process.env.PUBLIC_KEYCLOAK_URL}/realms/${process.env.PUBLIC_KEYCLOAK_REALM}`,
clientId: process.env.PUBLIC_KEYCLOAK_CLIENT_ID,
// Leave clientSecret undefined for public clients with PKCE
},
],
}),
],
});
auth-astro injects /api/auth/signin and /api/auth/callback/keycloak routes automatically. Call signIn('keycloak') from any client-side button and the library handles code exchange, session storage, and CSRF.
The tradeoff: auth-astro pins you to its session shape and adapter behavior. When you need to store custom claims, call downstream APIs with the access token, or implement a non-standard logout, the hand-rolled approach below gives you the control.
Building the Manual Login Route with PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception by binding the initial request to a one-time verifier known only to the initiating party. Even on a server-rendered app, PKCE is best practice because it protects the code-exchange leg.
Create src/pages/auth/login.ts:
// src/pages/auth/login.ts
import type { APIRoute } from 'astro';
import { generateCodeVerifier, generateCodeChallenge, generateState } from './pkce';
export const GET: APIRoute = async ({ redirect, cookies }) => {
const verifier = generateCodeVerifier(); // 43–128 char random string
const challenge = await generateCodeChallenge(verifier); // S256 hash
const state = generateState(); // CSRF guard
// Store verifier + state in short-lived cookies (httpOnly, 10 min TTL)
cookies.set('pkce_verifier', verifier, {
httpOnly: true,
sameSite: 'lax',
maxAge: 600,
path: '/',
});
cookies.set('oauth_state', state, {
httpOnly: true,
sameSite: 'lax',
maxAge: 600,
path: '/',
});
const keycloakBase = import.meta.env.PUBLIC_KEYCLOAK_URL;
const realm = import.meta.env.PUBLIC_KEYCLOAK_REALM;
const clientId = import.meta.env.PUBLIC_KEYCLOAK_CLIENT_ID;
const authorizeUrl = new URL(
`${keycloakBase}/realms/${realm}/protocol/openid-connect/auth`
);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('redirect_uri', 'http://localhost:4321/auth/callback');
authorizeUrl.searchParams.set('scope', 'openid email profile');
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('code_challenge', challenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
return redirect(authorizeUrl.toString(), 302);
};
And the small PKCE utility module at src/pages/auth/pkce.ts:
// src/pages/auth/pkce.ts
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/+/g, '-').replace(///g, '_').replace(/=/g, '');
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/+/g, '-').replace(///g, '_').replace(/=/g, '');
}
export function generateState(): string {
return crypto.randomUUID();
}
For a side-by-side comparison of how the same PKCE pattern is implemented in a React SPA, see secure React API access using Keycloak OIDC and PKCE.
Exchanging the Authorization Code in the Callback Route
Keycloak redirects the browser to your redirect_uri with ?code=...&state=.... The callback route must validate the state, exchange the code for tokens, and write a session cookie.
Create src/pages/auth/callback.ts:
// src/pages/auth/callback.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
const storedState = cookies.get('oauth_state')?.value;
const verifier = cookies.get('pkce_verifier')?.value;
// --- CSRF guard ---
if (!returnedState || returnedState !== storedState) {
return new Response('State mismatch — possible CSRF', { status: 400 });
}
if (!code || !verifier) {
return new Response('Missing code or verifier', { status: 400 });
}
// Clean up one-time cookies
cookies.delete('oauth_state', { path: '/' });
cookies.delete('pkce_verifier', { path: '/' });
const keycloakBase = import.meta.env.PUBLIC_KEYCLOAK_URL;
const realm = import.meta.env.PUBLIC_KEYCLOAK_REALM;
const clientId = import.meta.env.PUBLIC_KEYCLOAK_CLIENT_ID;
const tokenEndpoint = `${keycloakBase}/realms/${realm}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
redirect_uri: 'http://localhost:4321/auth/callback',
code_verifier: verifier,
});
const tokenRes = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!tokenRes.ok) {
const err = await tokenRes.text();
console.error('Token exchange failed:', err);
return new Response('Authentication failed', { status: 401 });
}
const { id_token, access_token, refresh_token } = await tokenRes.json();
// Decode the id_token payload (base64url, middle segment)
const [, payload] = id_token.split('.');
const user = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
// Store a signed session cookie (simplest approach — swap for Redis on high traffic)
const session = Buffer.from(
JSON.stringify({ sub: user.sub, email: user.email, name: user.name, access_token, refresh_token })
).toString('base64');
cookies.set('session', session, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 8, // 8 hours
path: '/',
});
return redirect('/', 302);
};
Watch out: The base64 session above is convenient for development but is not encrypted. In production, sign and encrypt the cookie using a library like
iron-sessionor move session data to Redis with only a session ID stored in the cookie. See our guide to session management in distributed systems for the full tradeoff analysis.
Protecting Routes with Astro Middleware
Astro middleware runs before every request, making it the right place to enforce authentication. Create src/middleware.ts:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
const PROTECTED_PATHS = ['/dashboard', '/account', '/settings'];
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = context.url;
const sessionCookie = context.cookies.get('session')?.value;
let user: { sub: string; email: string; name: string } | null = null;
if (sessionCookie) {
try {
const parsed = JSON.parse(
Buffer.from(sessionCookie, 'base64').toString('utf-8')
);
user = { sub: parsed.sub, email: parsed.email, name: parsed.name };
} catch {
// Corrupt cookie — treat as unauthenticated
context.cookies.delete('session', { path: '/' });
}
}
// Attach user to locals so page components can read it
context.locals.user = user;
// Guard protected paths
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
if (isProtected && !user) {
return context.redirect('/auth/login', 302);
}
return next();
});
Declare the type for Astro.locals in src/env.d.ts so TypeScript stays happy:
// src/env.d.ts
/// <reference types="astro/client" />
interface Locals {
user: { sub: string; email: string; name: string } | null;
}
Reading the Authenticated User in Page Components
Once middleware sets Astro.locals.user, any .astro page can read it without touching cookies directly:
---
// src/pages/dashboard.astro
const { user } = Astro.locals;
// Middleware already redirected unauthenticated requests, so user is guaranteed here
---
<html lang="en">
<head><title>Dashboard</title></head>
<body>
<h1>Welcome, {user!.name}</h1>
<p>Signed in as {user!.email}</p>
<a href="/auth/logout">Sign out</a>
</body>
</html>
The same Astro.locals.user value is available in API routes and server-rendered layouts — no prop-drilling or context providers needed.
The pattern mirrors how middleware-driven auth works in Next.js App Router. If you’re working across both frameworks, see Keycloak authentication in Next.js App Router for the equivalent implementation.
Implementing Logout via the end_session_endpoint
Logout has two parts: clear the local session cookie, then redirect the browser to Keycloak’s end_session_endpoint so the SSO session is also terminated. Without the second step, the user remains logged in to other apps sharing the same Keycloak realm.
Create src/pages/auth/logout.ts:
// src/pages/auth/logout.ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ cookies, redirect }) => {
const sessionCookie = cookies.get('session')?.value;
let idToken: string | undefined;
if (sessionCookie) {
try {
const parsed = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf-8'));
idToken = parsed.id_token; // store id_token at login time if you need RP-initiated logout
} catch { /* ignore */ }
}
// Clear local session
cookies.delete('session', { path: '/' });
const keycloakBase = import.meta.env.PUBLIC_KEYCLOAK_URL;
const realm = import.meta.env.PUBLIC_KEYCLOAK_REALM;
const clientId = import.meta.env.PUBLIC_KEYCLOAK_CLIENT_ID;
const logoutUrl = new URL(
`${keycloakBase}/realms/${realm}/protocol/openid-connect/logout`
);
logoutUrl.searchParams.set('client_id', clientId);
logoutUrl.searchParams.set('post_logout_redirect_uri', 'http://localhost:4321/');
if (idToken) {
logoutUrl.searchParams.set('id_token_hint', idToken);
}
return redirect(logoutUrl.toString(), 302);
};
Passing id_token_hint lets Keycloak 26.x skip the confirmation screen and redirect immediately. Without it, some Keycloak versions show an intermediate “Are you sure?” page.
For an overview of how OAuth 2.0 flows fit together before implementing more advanced scenarios, the OAuth 2.0 visual guide for developers is a useful reference.
Troubleshooting Common Issues
Here are the most frequent problems when connecting Astro to Keycloak and how to resolve them.
| Problem | Symptom | Solution |
|---|---|---|
redirect_uri_mismatch |
Keycloak 400 error on callback | Add exact URI (including port) to Valid Redirect URIs in the client settings |
| State mismatch | “State mismatch — possible CSRF” in callback | Cookie sameSite: 'lax' is required; strict blocks the OAuth redirect |
PKCE_NOT_ENABLED |
Keycloak 400 on token exchange | Ensure the client has “Standard flow” enabled and Client Authentication is off |
Empty Astro.locals.user |
Middleware always sees null | Confirm output: 'server' is set — static mode doesn’t run middleware |
| Logout doesn’t end SSO session | Re-login succeeds without credentials | Redirect to Keycloak’s end_session_endpoint with client_id and post_logout_redirect_uri |
If you need to run a managed Keycloak instance without managing server upkeep yourself, Skycloak’s managed Keycloak hosting provisions a production-ready realm in minutes and handles upgrades, backups, and SSL automatically.
Frequently asked questions
Can I use Keycloak with Astro in static mode?
No. Astro’s output: 'static' renders pages at build time and serves them as flat files from a CDN. Keycloak authentication requires per-request session validation, cookie writes, and server-side redirects — none of which are possible without a running server. Set output: 'server' or output: 'hybrid' and add a server adapter before implementing any auth flow.
Should I use a public client or a confidential client in Keycloak for an Astro app?
A public client with PKCE is the recommended choice for browser-facing Astro SSR apps. A confidential client requires a client_secret stored on the server; if you fully control the server environment and need token introspection or service-to-service calls, a confidential client is fine. For most Astro apps that just need user login, the public + PKCE path is simpler and equally secure.
What is the difference between auth-astro and a hand-rolled OIDC flow?
auth-astro wraps the OAuth exchange and session management behind a convention-based API, reducing setup time to under ten minutes. The hand-rolled approach using openid-client or arctic gives you full control over the session shape, token storage, custom claims, and logout behavior. Start with auth-astro for new projects; switch to manual when you need non-standard token handling or tighter integration with your backend. For how the same decision plays out in React, see secure React API access using Keycloak OIDC and PKCE.
How do I refresh the access token before it expires in Astro?
Store the refresh_token alongside the access_token in your session. In middleware, check the access token’s exp claim before forwarding the request. If it’s within 60 seconds of expiry, POST to /realms/{realm}/protocol/openid-connect/token with grant_type=refresh_token and write a new session cookie. If the refresh token is also expired, clear the session and redirect to /auth/login.
Can multiple Astro apps share a single Keycloak SSO session?
Yes — that’s one of Keycloak’s core strengths. Each app registers its own client in the same realm with its own redirect_uri. When a user authenticates against App A, Keycloak sets an SSO cookie on its own domain. When that user visits App B and hits /auth/login, Keycloak detects the existing session and issues a code without prompting for credentials again. Each app handles its own local session cookie independently. For more on OIDC concepts like session federation, see OpenID Connect explained for developers.
Next steps
You now have a working Keycloak OIDC integration in Astro: a login route that builds the authorize URL with PKCE, a callback route that exchanges the code for tokens, middleware that enforces route protection, page components that read the authenticated user via Astro.locals, and a logout route that terminates the Keycloak SSO session.
Extend this project:
- Add role-based access control by reading Keycloak realm roles from the decoded token’s
realm_access.rolesclaim in middleware. - Store the session in Redis instead of a cookie payload for horizontal scaling — see session management in distributed systems.
- Swap to
openid-clientv6 for automatic JWKS validation of theid_tokensignature instead of trusting the base64 decode. - Explore how the same patterns apply in Keycloak authentication in Next.js App Router if you run a mixed framework environment.
Running Keycloak yourself takes ongoing work — version upgrades, TLS cert rotation, database backups, and realm configuration drift across environments. If you’d rather focus on your Astro app, Skycloak’s managed Keycloak hosting gives you a production-ready realm with an uptime SLA, automatic 26.x upgrades, and a dashboard for managing clients and users — no Kubernetes expertise required.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.