Authentik は、Okta や Auth0 のような複雑さ (またはコスト) を必要とせずに、エンタープライズ グレードの SSO、OIDC、SAML、OAuth2 を提供するオープンソースのアイデンティティ プロバイダーです。セルフホスト型アプリケーションの場合、Authentik の OIDC エンドポイント、ユーザー管理、グループベースのアクセス制御、エンタープライズ ポリシーがすべて独自のインフラストラクチャの下で提供されます。
Authentik は、Okta や Auth0 のような複雑さ (またはコスト) を必要とせずに、エンタープライズ グレードの SSO、OIDC、SAML、OAuth2 を提供するオープンソースのアイデンティティ プロバイダーです。セルフホスト型アプリケーションの場合、Authentik の OIDC エンドポイント、ユーザー管理、グループベースのアクセス制御、エンタープライズ ポリシーがすべて独自のインフラストラクチャの下で提供されます。
このガイドでは、本番環境の Authentik と Next.js 16 および NestJS 11 の統合について説明します。OAuth2 プロバイダー構成、安全な認証コード フロー、ワンタイム交換コード パターン、JWT 検証、および認証実装の成否を分ける微妙な問題について説明します。
重要なポイント
- 認証トークンは HttpOnly Cookie 内に存在する必要があります。localStorage やクエリ パラメーター内には決して存在しないでください。
- ワンタイム交換コード (60 秒 TTL) を使用して、コールバックからフロントエンドにトークンを渡します
- サーバー間のトークン交換には常に
AUTHENTIK_INTERNAL_URLを使用します (ネットワーク ホップを回避します)- キャッシュから削除する前に交換コードの有効期限チェックを行う必要があります (競合状態の防止)
- API 経由で再生成された Authentik クライアント シークレットにはハッシュの不一致がある可能性があります - Django ORM を直接使用してください
- JWT クレームには
organizationIdが含まれている必要があります — Authentik プロパティ マッピングでエンコードします- Cookie からの JWT 抽出が機能する前に、NestJS で
cookie-parserミドルウェアが必要です- OIDC 検出エンドポイントはすべてのトークン/ユーザー情報 URL を提供します - ハードコーディングしないでください
認証設定
OAuth2 プロバイダーの作成
Authentik 管理パネル (/if/admin/):
- [アプリケーション] > [プロバイダ] > [作成] に移動します。
- OAuth2/OpenID プロバイダー を選択します
- 以下を設定します。
- 名前:
ECOSIRE Web OAuth2 - 認証フロー:
default-provider-authorization-implicit-consent - クライアント タイプ:
Confidential - クライアント ID:
ecosire-web(これを選択します) - リダイレクト URI:
https://api.ecosire.com/api/auth/callback https://ecosire.com/auth/callback - 署名キー: デフォルトの証明書を選択します
- スコープ:
openid、email、profile
- このプロバイダーを使用する アプリケーション を作成します
カスタム プロパティのマッピング
organizationId を JWT に含めるには、プロパティ マッピングを作成します。
Authentik 管理 > カスタマイズ > プロパティ マッピング > 作成 > スコープ マッピング で:
# Name: Organization ID Scope
# Scope name: organization
return {
"organizationId": request.user.attributes.get("organizationId", str(request.user.pk)),
"name": request.user.name,
}
このマッピングを OAuth2 プロバイダーの「スコープ」リストに追加し、アプリケーションによって要求されたスコープに organization を含めます。
バックエンド: NestJS 認証モジュール
JWT 戦略
// 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/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 });
}
}
認証サービス: コード交換パターン
ワンタイム交換コードは、Token-in-URL (ブラウザ履歴、サーバー ログ、リファラー ヘッダーに表示される) を回避するための鍵です。
// 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;
}
}
フロントエンド: コールバック ハンドラー
// 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>
);
}
クライアント シークレットのハッシュ問題の修正
Authentik の一般的な問題: REST API 経由で設定されたクライアント シークレットにハッシュの不一致があり、OIDC トークン交換が失敗することがあります。修正するには、Django ORM を介してシークレットを直接再生成します。
# 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
次に、.env.local の AUTHENTIK_CLIENT_SECRET を更新します。
本番環境変数
# 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
よくある質問
NextAuth.js ではなく Authentik を使用する理由
NextAuth.js は単純なアプリケーションに最適ですが、認証を Next.js アプリに結合します。 Authentik は、NestJS、モバイル アプリ、サードパーティ ツールなど、あらゆるフレームワークで動作するスタンドアロンの ID プロバイダーです。複数のアプリケーション間で SSO が必要な場合、SAML エンタープライズ ログインをサポートしたい場合、またはアプリとは別にユーザーとグループを管理するための UI が必要な場合は、Authentik がより良い選択肢です。
OIDC と OAuth2 の違いは何ですか?
OAuth2 は認証フレームワークであり、資格情報を共有せずにアクセスを許可する方法を定義します。 OIDC (OpenID Connect) は OAuth2 の上に構築され、認証を追加します。これは、ユーザーが誰であるかを確認する方法を指定します。 Authentik は両方をサポートしています。アプリケーションのログインには、OIDC (ユーザー クレームを含む ID トークンを提供します) が必要です。 OAuth2 のみは、「このアプリに Google ドライブへのアクセスを許可する」などのサードパーティ認証シナリオに使用します。
トークンの更新はどのように処理すればよいですか?
制限されたパス (/auth/refresh など) を使用して、リフレッシュ トークンを HttpOnly Cookie に保存します。アクセス トークンの有効期限が切れると、API は 401 を返し、フロントエンドは /auth/refresh を呼び出して、リフレッシュ トークンを使用して新しいアクセス トークンを取得します。更新エンドポイントは、Authentik と更新トークンを交換して新しいトークンを取得し、新しい Cookie を設定します。更新後の自動再試行により、API クライアントで 401 を処理します。
Authentik はエンタープライズ SAML プロバイダーを処理できますか?
はい — Authentik は、サービス プロバイダーとアイデンティティ プロバイダーの両方として SAML 2.0 をサポートします。 Okta、Azure AD、または Ping Identity を使用している企業顧客の場合、ユーザーが企業の資格情報を使用してログインできるように SAML フェデレーションを構成できます。 Authentik は SAML アサーションを OIDC トークンに変換するため、アプリケーション コードで SAML を直接処理する必要はありません。
認証フローをローカルでテストするにはどうすればよいですか?
アプリケーションと一緒にローカルで Docker Compose を使用して Authentik を実行します。 http://localhost:3000/auth/callback を含むようにリダイレクト URI を構成します。ローカル ユーザーで Authentik のテスト モードを使用します。交換コード フローの場合、60 秒の TTL はローカル開発には十分な量です。 OIDC フローをデバッグする必要がある場合、Authentik の管理パネルには、すべてのトークン交換試行がイベント ログに表示されます。
次のステップ
安全で本番環境に対応した認証システムの実装は、あらゆるアプリケーションにとって最も重要なエンジニアリング課題の 1 つです。 ECOSIRE は、HttpOnly Cookie ベースの認証、ワンタイム交換コード、NestJS と Next.js 間の完全な OIDC 統合を使用して、複数のアプリケーションに SSO を提供する Authentik を実稼働環境で実行します。
認証システム アーキテクチャ、Authentik 導入、または完全なエンタープライズ プラットフォームが必要な場合でも、当社の開発サービスをご覧ください して、当社がどのようにお手伝いできるかをご確認ください。
執筆者
ECOSIRE Research and Development Teamテクニカルライティング
ECOSIRE のテクニカルライティングチームは、Odoo ERP、Shopify による EC、AI エージェント、Power BI による分析、GoHighLevel による自動化、そしてエンタープライズソフトウェアのベストプラクティスを扱っています。私たちのガイドは、企業が十分な情報に基づいてテクノロジーを判断できるよう支援します。
関連記事
JWT 認証: 2026 年のセキュリティのベスト プラクティス
JWT のベスト プラクティスで API を保護します: RS256 と HS256、HttpOnly Cookie、トークン ローテーション、更新パターン、2026 年に回避すべき一般的な脆弱性。
最新のアプリケーションの API ゲートウェイ パターンとベスト プラクティス
スケーラブルな Web アーキテクチャ向けに、レート制限、認証、リクエスト ルーティング、サーキット ブレーカー、API バージョン管理などの API ゲートウェイ パターンを実装します。
ゼロトラスト アーキテクチャの実装: ビジネス向け実践ガイド
ID 検証、ネットワークのセグメンテーション、デバイスの信頼性、継続的な監視をカバーする実践的な手順を備えたゼロトラスト アーキテクチャを実装します。