Integrating with an Astro Application

Learn how to integrate Zitadel with an Astro application, configure authentication, and manage user sessions. We'll cover the PKCE flow, token introspection, and adding user roles.

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.

info

This tutorial uses Astro, but the concepts apply to most web frameworks. We’ll cover other frameworks like Remix, Next.js, and SvelteKit in upcoming videos.

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

The only dependency we need is Arctic, a fantastic OAuth library that simplifies integration:

Terminal window
bun add arctic

Arctic 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:

Terminal window
export ZITADEL_CLIENT_ID="your-client-id"
export ZITADEL_URL="https://your-instance.zitadel.cloud"
tip

With PKCE, there’s no client secret to manage! Your client ID is safe to commit to version control.

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/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">
<button>Login with Zitadel</button>
</a>

Testing the Integration

  1. Click the login button
  2. You’ll be redirected to Zitadel’s authentication page
  3. Log in or register a new account
  4. After successful authentication, you’ll be redirected back to your application
  5. 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.

Stay Updated

Sign up to receive notifications when new content is available for this course.

By signing up, you agree to receive course updates and notifications.