Authentik OIDC/SSO: Complete Integration Guide
Authentik is an open-source identity provider that gives you enterprise-grade SSO, OIDC, SAML, and OAuth2 without the complexity (or cost) of Okta or Auth0. For self-hosted applications, it provides Authentik's OIDC endpoints, user management, group-based access control, and enterprise policies — all under your own infrastructure.
This guide covers a production Authentik integration with Next.js 16 and NestJS 11: OAuth2 provider configuration, the secure authorization code flow, one-time exchange code pattern, JWT validation, and the subtle issues that make or break an auth implementation.
Key Takeaways
- Auth tokens must live in HttpOnly cookies — never in localStorage or query parameters
- Use a one-time exchange code (60-second TTL) to pass the token from callback to the frontend
- Always use
AUTHENTIK_INTERNAL_URLfor server-to-server token exchange (avoid network hops)- The exchange code expiry check must happen BEFORE deleting from cache (race condition prevention)
- Authentik client secrets regenerated via API may have a hashing mismatch — use Django ORM directly
- JWT claims must include
organizationId— encode it in the Authentik property mappingcookie-parsermiddleware is required in NestJS before JWT extraction from cookies works- OIDC discovery endpoint provides all token/userinfo URLs — don't hardcode them
Authentik Configuration
Creating the OAuth2 Provider
In the Authentik admin panel (/if/admin/):
-
Go to Applications > Providers > Create
-
Select OAuth2/OpenID Provider
-
Configure:
- Name:
ECOSIRE Web OAuth2 - Authorization flow:
default-provider-authorization-implicit-consent - Client type:
Confidential - Client ID:
ecosire-web(you choose this) - Redirect URIs:
https://api.ecosire.com/api/auth/callback https://ecosire.com/auth/callback - Signing Key: Select your default certificate
- Scopes:
openid,email,profile
- Name:
-
Create an Application that uses this provider
Custom Property Mapping
To include organizationId in the JWT, create a property mapping:
In Authentik admin > Customization > Property Mappings > Create > Scope Mapping:
# Name: Organization ID Scope
# Scope name: organization
return {
"organizationId": request.user.attributes.get("organizationId", str(request.user.pk)),
"name": request.user.name,
}
Add this mapping to your OAuth2 provider's "Scopes" list and include organization in the scopes requested by your application.
Backend: NestJS Auth Module
JWT Strategy
// auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
// Support both Cookie and Bearer token
jwtFromRequest: ExtractJwt.fromExtractors([
// Cookie first (web app)
(request) => {
return request?.cookies?.ecosire_auth ?? null;
},
// Bearer fallback (API clients, mobile)
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
// Validate against Authentik's public JWKS endpoint
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${process.env.AUTHENTIK_INTERNAL_URL}/application/o/ecosire-web/jwks/`,
}),
audience: 'ecosire-web',
issuer: `${process.env.AUTHENTIK_URL}/application/o/ecosire-web/`,
});
}
async validate(payload: {
sub: string;
email: string;
name: string;
organizationId?: string;
groups?: string[];
}) {
if (!payload.sub) {
throw new UnauthorizedException('Invalid token');
}
// Map Authentik claims to your internal user type
return {
sub: payload.sub,
email: payload.email,
name: payload.name,
organizationId: payload.organizationId ?? payload.sub,
role: this.mapGroupsToRole(payload.groups ?? []),
};
}
private mapGroupsToRole(groups: string[]): 'admin' | 'support' | 'user' {
if (groups.includes('ecosire-admins')) return 'admin';
if (groups.includes('ecosire-support')) return 'support';
return 'user';
}
}
Cookie Configuration
// main.ts
import cookieParser from 'cookie-parser';
const app = await NestFactory.create(AppModule);
// cookie-parser MUST be registered before JWT strategy runs
app.use(cookieParser(process.env.COOKIE_SECRET));
Auth Controller
// auth/auth.controller.ts
import {
Controller,
Get,
Post,
Query,
Res,
Req,
Body,
UnauthorizedException,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { Public } from './decorators/public.decorator';
import { AuthService } from './auth.service';
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 60 * 60 * 24 * 7 * 1000, // 7 days
};
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('login')
@Public()
login(@Res() res: Response) {
const params = new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID!,
redirect_uri: `${process.env.API_URL}/auth/callback`,
response_type: 'code',
scope: 'openid email profile organization',
state: this.authService.generateState(),
});
const authUrl = `${process.env.AUTHENTIK_URL}/application/o/authorize/?${params}`;
return res.redirect(authUrl);
}
@Get('callback')
@Public()
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Res() res: Response
) {
// Exchange authorization code for tokens
const { accessToken, refreshToken } = await this.authService.exchangeCode(code, state);
// Create a one-time exchange code for the frontend
// This avoids putting the token in the redirect URL
const exchangeCode = await this.authService.createExchangeCode(accessToken, refreshToken);
// Redirect to frontend with the one-time code (not the token itself)
return res.redirect(
`${process.env.FRONTEND_URL}/auth/callback?exchange=${exchangeCode}`
);
}
@Post('exchange')
@Public()
async exchangeTokens(
@Body() body: { code: string },
@Res() res: Response
) {
// Frontend exchanges the one-time code for HttpOnly cookies
const tokens = await this.authService.redeemExchangeCode(body.code);
if (!tokens) {
throw new UnauthorizedException('Invalid or expired exchange code');
}
// Set HttpOnly cookies — tokens never in response body
res.cookie('ecosire_auth', tokens.accessToken, COOKIE_OPTS);
res.cookie('ecosire_refresh', tokens.refreshToken, {
...COOKIE_OPTS,
path: '/auth/refresh', // Refresh token only sent to refresh endpoint
});
return res.json({ success: true });
}
@Get('session')
async getSession(@Req() req: Request) {
// JWT guard already validated the cookie, user attached to req
return req.user; // sub, email, name, role, organizationId
}
@Post('logout')
async logout(@Res() res: Response) {
// Clear cookies with the SAME options used to set them
res.clearCookie('ecosire_auth', COOKIE_OPTS);
res.clearCookie('ecosire_refresh', {
...COOKIE_OPTS,
path: '/auth/refresh',
});
// Optionally: End Authentik session
const logoutUrl = `${process.env.AUTHENTIK_URL}/application/o/ecosire-web/end-session/`;
return res.json({ logoutUrl });
}
}
Auth Service: Exchange Code Pattern
The one-time exchange code is the key to avoiding token-in-URL (which appears in browser history, server logs, and referrer headers):
// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Redis } from 'ioredis';
import { nanoid } from 'nanoid';
import axios from 'axios';
@Injectable()
export class AuthService {
constructor(private redis: Redis) {}
async exchangeCode(code: string, state: string) {
// Verify state matches (CSRF protection)
const storedState = await this.redis.get(`auth:state:${state}`);
if (!storedState) {
throw new UnauthorizedException('Invalid state parameter');
}
await this.redis.del(`auth:state:${state}`);
// Exchange authorization code for tokens using internal URL
// AUTHENTIK_INTERNAL_URL avoids going through Nginx/Cloudflare for
// server-to-server calls inside the same network
const response = await axios.post(
`${process.env.AUTHENTIK_INTERNAL_URL}/application/o/token/`,
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${process.env.API_URL}/auth/callback`,
client_id: process.env.AUTHENTIK_CLIENT_ID!,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET!,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
};
}
async createExchangeCode(
accessToken: string,
refreshToken: string
): Promise<string> {
const code = nanoid(32);
const payload = JSON.stringify({ accessToken, refreshToken });
// 60-second TTL — enough for the redirect to complete
await this.redis.setex(`auth:exchange:${code}`, 60, payload);
return code;
}
async redeemExchangeCode(code: string) {
const key = `auth:exchange:${code}`;
// Check expiry BEFORE deleting (prevents race condition)
const ttl = await this.redis.ttl(key);
if (ttl <= 0) {
return null; // Already expired or doesn't exist
}
const data = await this.redis.getdel(key); // Atomic get-and-delete
if (!data) return null;
return JSON.parse(data);
}
generateState(): string {
const state = nanoid(32);
// Store state with 10-minute TTL
this.redis.setex(`auth:state:${state}`, 600, '1');
return state;
}
async upsertUser(payload: {
sub: string;
email: string;
name: string;
organizationId: string;
}) {
const [user] = await db
.insert(users)
.values({
id: payload.sub,
email: payload.email,
name: payload.name,
organizationId: payload.organizationId,
})
.onConflictDoUpdate({
target: users.id,
set: {
email: payload.email,
name: payload.name,
lastLoginAt: new Date(),
},
})
.returning();
return user;
}
}
Frontend: Callback Handler
// app/auth/callback/page.tsx — Next.js page
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function AuthCallbackPage() {
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
const exchangeCode = params.get('exchange');
const redirectTo = params.get('redirect') ?? '/dashboard';
if (!exchangeCode) {
router.push('/auth/login?error=no_code');
return;
}
// Prevent open redirect
const safeRedirect = redirectTo.startsWith('/') && !redirectTo.startsWith('//')
? redirectTo
: '/dashboard';
// Exchange one-time code for HttpOnly cookies
fetch('/api/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: exchangeCode }),
credentials: 'include', // Required for cookie setting
})
.then((res) => {
if (res.ok) {
router.push(safeRedirect);
} else {
router.push('/auth/login?error=exchange_failed');
}
})
.catch(() => router.push('/auth/login?error=network'));
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
<p>Signing you in...</p>
</div>
);
}
Fixing the Client Secret Hashing Issue
A common Authentik issue: client secrets set via the REST API sometimes have a hashing mismatch and fail OIDC token exchange. The fix is to regenerate the secret via Django ORM directly:
# Run through the authentik container's shell
cat > /tmp/regen_secret.py << 'EOF'
from authentik.providers.oauth2.models import OAuth2Provider
import secrets
provider = OAuth2Provider.objects.get(name="ECOSIRE Web OAuth2")
new_secret = secrets.token_urlsafe(64)
provider.client_secret = new_secret
provider.save()
print(f"New secret: {new_secret}")
EOF
docker exec -i authentik-worker /lifecycle/ak shell < /tmp/regen_secret.py
Then update AUTHENTIK_CLIENT_SECRET in your .env.local.
Production Environment Variables
# Authentik
AUTHENTIK_URL=https://auth.ecosire.com
AUTHENTIK_INTERNAL_URL=http://localhost:9000 # Server-to-server
AUTHENTIK_CLIENT_ID=ecosire-web
AUTHENTIK_CLIENT_SECRET=your-generated-secret
# App URLs
API_URL=https://api.ecosire.com/api
FRONTEND_URL=https://ecosire.com
# Cookie security
COOKIE_SECRET=random-32-char-string-for-signing
NODE_ENV=production
Frequently Asked Questions
Why use Authentik instead of NextAuth.js?
NextAuth.js is a great choice for simple applications, but it couples auth to your Next.js app. Authentik is a standalone identity provider that works with any framework — NestJS, mobile apps, third-party tools. If you need SSO across multiple applications, want to support SAML enterprise login, or need a UI for managing users and groups separate from your app, Authentik is the better choice.
What's the difference between OIDC and OAuth2?
OAuth2 is an authorization framework — it defines how to grant access without sharing credentials. OIDC (OpenID Connect) is built on top of OAuth2 and adds authentication — it specifies how to verify who a user is. Authentik supports both. For your application login, you want OIDC (which gives you an ID token with user claims). OAuth2 alone is for third-party authorization scenarios like "allow this app to access my Google Drive."
How do I handle token refresh?
Store the refresh token in an HttpOnly cookie with a restricted path (e.g., /auth/refresh). When the access token expires, your API returns 401, and your frontend calls /auth/refresh to get a new access token using the refresh token. The refresh endpoint exchanges the refresh token with Authentik for new tokens and sets new cookies. Handle the 401 in your API client with automatic retry after refresh.
Can Authentik handle enterprise SAML providers?
Yes — Authentik supports SAML 2.0 as both a Service Provider and Identity Provider. For enterprise customers using Okta, Azure AD, or Ping Identity, you can configure SAML federation so users log in with their corporate credentials. Authentik translates SAML assertions into OIDC tokens, so your application code doesn't need to handle SAML directly.
How do I test auth flows locally?
Run Authentik with Docker Compose locally alongside your application. Configure redirect URIs to include http://localhost:3000/auth/callback. Use Authentik's test mode with a local user. For the exchange code flow, the 60-second TTL is generous enough for local development. If you need to debug the OIDC flow, Authentik's admin panel shows all token exchange attempts in the Events log.
Next Steps
Implementing a secure, production-ready auth system is one of the most critical engineering challenges for any application. ECOSIRE runs Authentik in production serving SSO for multiple applications, with HttpOnly cookie-based auth, one-time exchange codes, and full OIDC integration between NestJS and Next.js.
Whether you need auth system architecture, Authentik deployment, or a complete enterprise platform, explore our development services to see how we can help.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
API Security 2026: Authentication & Authorization Best Practices (OWASP Aligned)
OWASP-aligned 2026 API security guide: OAuth 2.1, PASETO/JWT, passkeys, RBAC/ABAC/OPA, rate limiting, secrets management, audit logging, and the top 10 mistakes.
JWT Authentication: Security Best Practices in 2026
Secure your APIs with JWT best practices: RS256 vs HS256, HttpOnly cookies, token rotation, refresh patterns, and common vulnerabilities to avoid in 2026.
API Gateway Patterns and Best Practices for Modern Applications
Implement API gateway patterns including rate limiting, authentication, request routing, circuit breakers, and API versioning for scalable web architectures.