Building on Previous Knowledge
In the previous video, we set up a basic application that could authenticate with Zitadel and visualize the JWT. Now we’re taking it a step further by integrating Zitadel into a real web application using Astro.
What We’ll Build
In this tutorial, we’ll:
- Set up an Astro application with server-side rendering
- Configure Zitadel OAuth with PKCE flow
- Implement login and callback endpoints
- Handle JWT tokens and user sessions
- Display user information after authentication
Prerequisites
Before starting, make sure you have:
- An Astro application (created with
bun create astro@latest) - A Zitadel instance (Cloud or self-hosted)
- Basic understanding of OAuth 2.0 and PKCE
Setting Up Dependencies
Install Arctic for OAuth helpers, the JWT decoder used below, and the ID-token claim types:
bun add arctic @oslojs/jwtbun add -d oidc-client-tsArctic handles all the heavy lifting for OAuth and OIDC flows, making our integration straightforward.
Environment Configuration
Create an .envrc file (or .env if you prefer) with your Zitadel configuration:
export ZITADEL_CLIENT_ID="your-client-id"export ZITADEL_URL="https://your-instance.zitadel.cloud"Configuring Astro
Update your astro.config.mjs to enable server-side rendering and configure environment variables:
import { defineConfig } from 'astro/config';
export default defineConfig({ output: 'server', env: { schema: { ZITADEL_URL: { type: 'string', context: 'server', access: 'public', }, ZITADEL_CLIENT_ID: { type: 'string', context: 'server', access: 'public', }, }, validateSecrets: true, },});Creating the Zitadel Integration
Create a lib/zitadel.ts file to handle the OAuth flow:
import { ZITADEL_URL, ZITADEL_CLIENT_ID } from 'astro:env/server';import { generateCodeChallenge, generateState } from 'arctic';import { decodeJWT } from '@oslojs/jwt';import type { IdTokenClaims } from 'oidc-client-ts';
export class Zitadel { private authorizationEndpoint: string; private tokenEndpoint: string; private redirectURI: string;
constructor() { this.authorizationEndpoint = `${ZITADEL_URL}/oauth/v2/authorize`; this.tokenEndpoint = `${ZITADEL_URL}/oauth/v2/token`; this.redirectURI = 'http://localhost:4321/api/auth/callback'; }
async createAuthorizationURL(state: string, codeChallenge: string, scopes: string[]): Promise<URL> { const url = new URL(this.authorizationEndpoint); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', ZITADEL_CLIENT_ID); url.searchParams.set('redirect_uri', this.redirectURI); url.searchParams.set('state', state); url.searchParams.set('scope', scopes.join(' ')); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('code_challenge', codeChallenge); return url; }
async validateAuthorizationCode(code: string, codeVerifier: string) { const response = await fetch(this.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: ZITADEL_CLIENT_ID, redirect_uri: this.redirectURI, code, code_verifier: codeVerifier, }), });
const tokens = await response.json();
// Optionally decode the ID token to get user claims if (tokens.id_token) { const claims = decodeJWT(tokens.id_token) as IdTokenClaims; return { ...tokens, claims }; }
return tokens; }}Implementing Authentication Endpoints
Sign-in Endpoint
Create pages/api/auth/signin.ts:
import type { APIRoute } from 'astro';import { generateState, generateCodeChallenge } from 'arctic';import { Zitadel } from '@/lib/zitadel';
export const prerender = false;
export const GET: APIRoute = async ({ cookies, redirect }) => { const zitadel = new Zitadel(); const state = generateState(); const codeVerifier = generateCodeChallenge(); const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store state and verifier in cookies for validation cookies.set('state', state, { httpOnly: true, secure: import.meta.env.PROD, sameSite: 'lax', maxAge: 60 * 10, // 10 minutes });
cookies.set('code_verifier', codeVerifier, { httpOnly: true, secure: import.meta.env.PROD, sameSite: 'lax', maxAge: 60 * 10, });
const authUrl = await zitadel.createAuthorizationURL( state, codeChallenge, ['openid', 'profile', 'email'] );
return redirect(authUrl.toString());};Callback Endpoint
Create pages/api/auth/callback.ts:
import type { APIRoute } from 'astro';import { Zitadel } from '@/lib/zitadel';
export const prerender = false;
export const GET: APIRoute = async ({ url, cookies, redirect }) => { const code = url.searchParams.get('code'); const state = url.searchParams.get('state');
if (!code || !state) { return new Response('Invalid request', { status: 400 }); }
const storedState = cookies.get('state')?.value; const codeVerifier = cookies.get('code_verifier')?.value;
if (!storedState || !codeVerifier || state !== storedState) { return new Response('Invalid state', { status: 400 }); }
// Clear temporary cookies cookies.delete('state'); cookies.delete('code_verifier');
try { const zitadel = new Zitadel(); const tokens = await zitadel.validateAuthorizationCode(code, codeVerifier);
// Store tokens in secure cookies cookies.set('access_token', tokens.access_token, { httpOnly: true, secure: import.meta.env.PROD, sameSite: 'lax', maxAge: tokens.expires_in, });
if (tokens.refresh_token) { cookies.set('refresh_token', tokens.refresh_token, { httpOnly: true, secure: import.meta.env.PROD, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, // 30 days }); }
return redirect('/'); } catch (error) { console.error('Authentication error:', error); return new Response('Authentication failed', { status: 500 }); }};Connecting the Login Button
Update your login button to trigger the authentication flow:
<a href="/api/auth/signin">Login with Zitadel</a>Testing the Integration
- Click the login button
- You’ll be redirected to Zitadel’s authentication page
- Log in or register a new account
- After successful authentication, you’ll be redirected back to your application
- Check your browser’s developer tools to see the stored tokens
Managing Multiple Accounts
Zitadel has excellent multi-account support. Users can:
- Switch between different accounts seamlessly
- Register new accounts during the login flow
- Manage their profiles and settings
As an administrator, you can:
- View and manage all users in the Zitadel console
- Verify email addresses
- Reset passwords
- Configure multi-factor authentication
- Set up roles and permissions
Next Steps
Now that we have basic authentication working, in future videos we’ll explore:
- Role-Based Access Control (RBAC)
- Refresh token handling
- User profile management
- Social login providers
- Advanced authorization patterns
Summary
Integrating Zitadel with Astro is straightforward thanks to:
- The PKCE flow eliminating the need for client secrets
- Arctic library handling OAuth complexity
- Astro’s built-in support for server-side rendering and API routes
You can reuse the Zitadel class across multiple projects or publish it as a package for your team.