Skip to main content

Authentik OIDC/SSO: Complete Integration Guide

Complete Authentik OIDC and SSO integration guide: OAuth2 provider setup, Next.js callback handling, NestJS JWT validation, user provisioning, and production configuration.

E
ECOSIRE Research and Development Team
|March 19, 202610 min read1.9k Words|

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.

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_URL for 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 mapping
  • cookie-parser middleware 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/):

  1. Go to Applications > Providers > Create

  2. Select OAuth2/OpenID Provider

  3. 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
  4. 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';
  }
}
// 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.

E

Written by

ECOSIRE Research and Development Team

Technical 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.