How to validate access tokens in your API service or backend
Validating access tokens is a critical part of enforcing role-based access control (RBAC) in Logto. This guide walks you through verifying Logto-issued JWTs in your backend/API, checking for signature, issuer, audience, expiration, permissions (scopes), and organization context.
Before you start
- This guide assumes you are familiar with Logto’s RBAC concepts.
- If you are protecting API resources, this guide assumes you have gone through the Protect global API resources guide.
- If you are protecting in-app features or workflows (non-API permissions), this guide assumes you have gone through the Protect organization (non-API) permissions guide.
- If you are protecting organization-level API resources, this guide assumes you have gone through the Protect organization-level API resources guide.
Step 1: Initialize constants and utilities
Define necessary constants and utilities in your code to handle token extraction and validation. A valid request must include an Authorization
header in the form Bearer <access_token>
.
- Node.js
- Python
- Go
- Java
- .NET
- PHP
- Ruby
- Rust
import { IncomingHttpHeaders } from 'http';
const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
const ISSUER = 'https://your-tenant.logto.app/oidc';
export class AuthInfo {
constructor(
public sub: string,
public clientId?: string,
public organizationId?: string,
public scopes: string[] = [],
public audience: string[] = []
) {}
}
export class AuthorizationError extends Error {
name = 'AuthorizationError';
constructor(
message: string,
public status = 403
) {
super(message);
}
}
export function extractBearerTokenFromHeaders({ authorization }: IncomingHttpHeaders): string {
const bearerPrefix = 'Bearer ';
if (!authorization) {
throw new AuthorizationError('Authorization header is missing', 401);
}
if (!authorization.startsWith(bearerPrefix)) {
throw new AuthorizationError(`Authorization header must start with "${bearerPrefix}"`, 401);
}
return authorization.slice(bearerPrefix.length);
}
JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks'
ISSUER = 'https://your-tenant.logto.app/oidc'
class AuthInfo:
def __init__(self, sub: str, client_id: str = None, organization_id: str = None,
scopes: list = None, audience: list = None):
self.sub = sub
self.client_id = client_id
self.organization_id = organization_id
self.scopes = scopes or []
self.audience = audience or []
def to_dict(self):
return {
'sub': self.sub,
'client_id': self.client_id,
'organization_id': self.organization_id,
'scopes': self.scopes,
'audience': self.audience
}
class AuthorizationError(Exception):
def __init__(self, message: str, status: int = 403):
self.message = message
self.status = status
super().__init__(self.message)
def extract_bearer_token_from_headers(headers: dict) -> str:
"""
Extract bearer token from HTTP headers.
Note: FastAPI and Django REST Framework have built-in token extraction,
so this function is primarily for Flask and other frameworks.
"""
authorization = headers.get('authorization') or headers.get('Authorization')
if not authorization:
raise AuthorizationError('Authorization header is missing', 401)
if not authorization.startswith('Bearer '):
raise AuthorizationError('Authorization header must start with "Bearer "', 401)
return authorization[7:] # Remove 'Bearer ' prefix
package main
import (
"fmt"
"net/http"
"strings"
)
const (
JWKS_URI = "https://your-tenant.logto.app/oidc/jwks"
ISSUER = "https://your-tenant.logto.app/oidc"
)
type AuthorizationError struct {
Message string
Status int
}
func (e *AuthorizationError) Error() string {
return e.Message
}
func NewAuthorizationError(message string, status ...int) *AuthorizationError {
statusCode := http.StatusForbidden // Default to 403 Forbidden
if len(status) > 0 {
statusCode = status[0]
}
return &AuthorizationError{
Message: message,
Status: statusCode,
}
}
func extractBearerTokenFromHeaders(r *http.Request) (string, error) {
const bearerPrefix = "Bearer "
authorization := r.Header.Get("Authorization")
if authorization == "" {
return "", NewAuthorizationError("Authorization header is missing", http.StatusUnauthorized)
}
if !strings.HasPrefix(authorization, bearerPrefix) {
return "", NewAuthorizationError(fmt.Sprintf("Authorization header must start with \"%s\"", bearerPrefix), http.StatusUnauthorized)
}
return strings.TrimPrefix(authorization, bearerPrefix), nil
}
public class AuthorizationException extends RuntimeException {
private final int statusCode;
public AuthorizationException(String message) {
this(message, 403); // Default to 403 Forbidden
}
public AuthorizationException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public int getStatusCode() {
return statusCode;
}
}
namespace YourApiNamespace
{
public static class AuthConstants
{
public const string Issuer = "https://your-tenant.logto.app/oidc";
}
}
namespace YourApiNamespace.Exceptions
{
public class AuthorizationException : Exception
{
public int StatusCode { get; }
public AuthorizationException(string message, int statusCode = 403) : base(message)
{
StatusCode = statusCode;
}
}
}
<?php
class AuthConstants
{
public const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
public const ISSUER = 'https://your-tenant.logto.app/oidc';
}
<?php
class AuthInfo
{
public function __construct(
public readonly string $sub,
public readonly ?string $clientId = null,
public readonly ?string $organizationId = null,
public readonly array $scopes = [],
public readonly array $audience = []
) {}
public function toArray(): array
{
return [
'sub' => $this->sub,
'client_id' => $this->clientId,
'organization_id' => $this->organizationId,
'scopes' => $this->scopes,
'audience' => $this->audience,
];
}
}
<?php
class AuthorizationException extends Exception
{
public function __construct(
string $message,
public readonly int $statusCode = 403
) {
parent::__construct($message);
}
}
<?php
trait AuthHelpers
{
protected function extractBearerToken(array $headers): string
{
$authorization = $headers['authorization'][0] ?? $headers['Authorization'][0] ?? null;
if (!$authorization) {
throw new AuthorizationException('Authorization header is missing', 401);
}
if (!str_starts_with($authorization, 'Bearer ')) {
throw new AuthorizationException('Authorization header must start with "Bearer "', 401);
}
return substr($authorization, 7); // Remove 'Bearer ' prefix
}
}
module AuthConstants
JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks'
ISSUER = 'https://your-tenant.logto.app/oidc'
end
class AuthInfo
attr_accessor :sub, :client_id, :organization_id, :scopes, :audience
def initialize(sub, client_id = nil, organization_id = nil, scopes = [], audience = [])
@sub = sub
@client_id = client_id
@organization_id = organization_id
@scopes = scopes
@audience = audience
end
def to_h
{
sub: @sub,
client_id: @client_id,
organization_id: @organization_id,
scopes: @scopes,
audience: @audience
}
end
end
class AuthorizationError < StandardError
attr_reader :status
def initialize(message, status = 403)
super(message)
@status = status
end
end
module AuthHelpers
def extract_bearer_token(request)
authorization = request.headers['Authorization']
raise AuthorizationError.new('Authorization header is missing', 401) unless authorization
raise AuthorizationError.new('Authorization header must start with "Bearer "', 401) unless authorization.start_with?('Bearer ')
authorization[7..-1] # Remove 'Bearer ' prefix
end
end
use serde::{Deserialize, Serialize};
use std::fmt;
pub const JWKS_URI: &str = "https://your-tenant.logto.app/oidc/jwks";
pub const ISSUER: &str = "https://your-tenant.logto.app/oidc";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthInfo {
pub sub: String,
pub client_id: Option<String>,
pub organization_id: Option<String>,
pub scopes: Vec<String>,
pub audience: Vec<String>,
}
impl AuthInfo {
pub fn new(
sub: String,
client_id: Option<String>,
organization_id: Option<String>,
scopes: Vec<String>,
audience: Vec<String>,
) -> Self {
Self {
sub,
client_id,
organization_id,
scopes,
audience,
}
}
}
#[derive(Debug)]
pub struct AuthorizationError {
pub message: String,
pub status_code: u16,
}
impl AuthorizationError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
status_code: 403,
}
}
pub fn with_status(message: impl Into<String>, status_code: u16) -> Self {
Self {
message: message.into(),
status_code,
}
}
}
impl fmt::Display for AuthorizationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AuthorizationError {}
pub fn extract_bearer_token(authorization: Option<&str>) -> Result<&str, AuthorizationError> {
let auth_header = authorization.ok_or_else(|| {
AuthorizationError::with_status("Authorization header is missing", 401)
})?;
if !auth_header.starts_with("Bearer ") {
return Err(AuthorizationError::with_status(
"Authorization header must start with \"Bearer \"",
401,
));
}
Ok(&auth_header[7..]) // Remove 'Bearer ' prefix
}
Step 2: Retrieve info about your Logto tenant
You’ll need the following values to validate Logto-issued tokens:
- JSON Web Key Set (JWKS) URI: The URL to Logto’s public keys, used to verify JWT signatures.
- Issuer: The expected issuer value (Logto’s OIDC URL).
First, find your Logto tenant’s endpoint. You can find it in various places:
- In the Logto Console, under Settings → Domains.
- In any application settings where you configured in Logto, Settings → Endpoints & Credentials.
Fetch from OpenID Connect discovery endpoint
These values can be retrieved from Logto’s OpenID Connect discovery endpoint:
https://<your-logto-endpoint>/oidc/.well-known/openid-configuration
Here’s an example response (other fields omitted for brevity):
{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}
Hardcode in your code (not recommended)
Since Logto doesn't allow customizing the JWKS URI or issuer, you can hardcode these values in your code. However, this is not recommended for production applications as it may increase maintenance overhead if some configuration changes in the future.
- JWKS URI:
https://<your-logto-endpoint>/oidc/jwks
- Issuer:
https://<your-logto-endpoint>/oidc
Step 3: Validate the token and permissions
After extracting the token and fetching the OIDC config, validate the following:
- Signature: JWT must be valid and signed by Logto (via JWKS).
- Issuer: Must match your Logto tenant’s issuer.
- Audience: Must match the API’s resource indicator registered in Logto, or the organization context if applicable.
- Expiration: Token must not be expired.
- Permissions (scopes): Token must include required scopes for your API/action. Scopes are space-separated strings in the
scope
claim. - Organization context: If protecting organization-level API resources, validate the
organization_id
claim.
See JSON Web Token to learn more about JWT structure and claims.
What to check for each permission model
The claims and validation rules differ by permission model:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
- Audience claim (
aud
): API resource indicator - Organization claim (
organization_id
): Not present - Scopes (permissions) to check (
scope
): API resource permissions
- Audience claim (
aud
):urn:logto:organization:<id>
(organization context is inaud
claim) - Organization claim (
organization_id
): Not present - Scopes (permissions) to check (
scope
): Organization permissions
- Audience claim (
aud
): API resource indicator - Organization claim (
organization_id
): Organization ID (must match request) - Scopes (permissions) to check (
scope
): API resource permissions
For non-API organization permissions, the organization context is represented by the aud
claim
(e.g., urn:logto:organization:abc123
). The organization_id
claim is only present for
organization-level API resource tokens.
Always validate both permissions (scopes) and context (audience, organization) for secure multi-tenant APIs.
Add the validation logic
- Node.js
- Python
- Go
- Java
- .NET
- PHP
- Ruby
- Rust
We use jose in this example to validate the JWT. Install it if you haven't already:
npm install jose
Or use your preferred package manager (e.g., pnpm
or yarn
).
First, add these shared utilities to handle JWT validation:
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import { AuthInfo, AuthorizationError } from './auth-middleware.js';
const jwks = createRemoteJWKSet(new URL(JWKS_URI));
export async function validateJwt(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, jwks, {
issuer: ISSUER,
});
verifyPayload(payload);
return payload;
}
export function createAuthInfo(payload: JWTPayload): AuthInfo {
const scopes = (payload.scope as string)?.split(' ') ?? [];
const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
return new AuthInfo(
payload.sub!,
payload.client_id as string,
payload.organization_id as string,
scopes,
audience
);
}
function verifyPayload(payload: JWTPayload): void {
// Implement your verification logic here based on permission model
// This will be shown in the permission models section below
}
Then, implement the middleware to verify the access token:
- Express.js
- Fastify
- Hapi.js
- Koa.js
- NestJS
import { Request, Response, NextFunction } from 'express';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
// Extend Express Request interface to include auth
declare global {
namespace Express {
interface Request {
auth?: AuthInfo;
}
}
}
export async function verifyAccessToken(req: Request, res: Response, next: NextFunction) {
try {
const token = extractBearerTokenFromHeaders(req.headers);
const payload = await validateJwt(token);
// Store auth info in request for generic use
req.auth = createAuthInfo(payload);
next();
} catch (err: any) {
return res.status(err.status ?? 401).json({ error: err.message });
}
}
import { FastifyRequest, FastifyReply } from 'fastify';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
// Extend Fastify Request interface to include auth
declare module 'fastify' {
interface FastifyRequest {
auth?: AuthInfo;
}
}
export async function fastifyVerifyAccessToken(request: FastifyRequest, reply: FastifyReply) {
try {
const token = extractBearerTokenFromHeaders(request.headers);
const payload = await validateJwt(token);
// Store auth info in request for generic use
request.auth = createAuthInfo(payload);
} catch (err: any) {
reply.code(err.status ?? 401).send({ error: err.message });
}
}
import { Request, ResponseToolkit } from '@hapi/hapi';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
export async function hapiVerifyAccessToken(request: Request, h: ResponseToolkit) {
try {
const token = extractBearerTokenFromHeaders(request.headers);
const payload = await validateJwt(token);
// Store auth info in request.app for generic use
request.app.auth = createAuthInfo(payload);
return h.continue;
} catch (err: any) {
return h
.response({ error: err.message })
.code(err.status ?? 401)
.takeover();
}
}
import { Context, Next } from 'koa';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
export async function koaVerifyAccessToken(ctx: Context, next: Next) {
try {
const token = extractBearerTokenFromHeaders(ctx.request.headers);
const payload = await validateJwt(token);
// Store auth info in state for generic use
ctx.state.auth = createAuthInfo(payload);
await next();
} catch (err: any) {
ctx.status = err.status ?? 401;
ctx.body = { error: err.message };
}
}
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { validateJwt, createAuthInfo } from './jwt-validator.js';
@Injectable()
export class AccessTokenGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
try {
const token = extractBearerTokenFromHeaders(req.headers);
const payload = await validateJwt(token);
// Store auth info in request for generic use
req.auth = createAuthInfo(payload);
return true;
} catch (err: any) {
if (err.status === 401) throw new UnauthorizedException(err.message);
throw new ForbiddenException(err.message);
}
}
}
According to your permission model, implement the appropriate verification logic in jwt-validator.ts
:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
function verifyPayload(payload: JWTPayload): void {
// Check audience claim matches your API resource indicator
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
if (!audiences.includes('https://your-api-resource-indicator')) {
throw new AuthorizationError('Invalid audience');
}
// Check required scopes for global API resources
const requiredScopes = ['api:read', 'api:write']; // Replace with your actual required scopes
const scopes = (payload.scope as string)?.split(' ') ?? [];
if (!requiredScopes.every((scope) => scopes.includes(scope))) {
throw new AuthorizationError('Insufficient scope');
}
}
function verifyPayload(payload: JWTPayload): void {
// Check audience claim matches organization format
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
const hasOrgAudience = audiences.some((aud) => aud.startsWith('urn:logto:organization:'));
if (!hasOrgAudience) {
throw new AuthorizationError('Invalid audience for organization permissions');
}
// Check organization ID matches the context (you may need to extract this from request context)
const expectedOrgId = 'your-organization-id'; // Extract from request context
const expectedAud = `urn:logto:organization:${expectedOrgId}`;
if (!audiences.includes(expectedAud)) {
throw new AuthorizationError('Organization ID mismatch');
}
// Check required organization scopes
const requiredScopes = ['invite:users', 'manage:settings']; // Replace with your actual required scopes
const scopes = (payload.scope as string)?.split(' ') ?? [];
if (!requiredScopes.every((scope) => scopes.includes(scope))) {
throw new AuthorizationError('Insufficient organization scope');
}
}
function verifyPayload(payload: JWTPayload): void {
// Check audience claim matches your API resource indicator
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
if (!audiences.includes('https://your-api-resource-indicator')) {
throw new AuthorizationError('Invalid audience for organization-level API resources');
}
// Check organization ID matches the context (you may need to extract this from request context)
const expectedOrgId = 'your-organization-id'; // Extract from request context
const orgId = payload.organization_id as string;
if (expectedOrgId !== orgId) {
throw new AuthorizationError('Organization ID mismatch');
}
// Check required scopes for organization-level API resources
const requiredScopes = ['api:read', 'api:write']; // Replace with your actual required scopes
const scopes = (payload.scope as string)?.split(' ') ?? [];
if (!requiredScopes.every((scope) => scopes.includes(scope))) {
throw new AuthorizationError('Insufficient organization-level API scopes');
}
}
We use PyJWT to validate JWTs. Install it if you haven't already:
pip install pyjwt[crypto]
First, add these shared utilities to handle JWT validation:
import jwt
from jwt import PyJWKClient
from typing import Dict, Any
from auth_middleware import AuthInfo, AuthorizationError, JWKS_URI, ISSUER
jwks_client = PyJWKClient(JWKS_URI)
def validate_jwt(token: str) -> Dict[str, Any]:
"""Validate JWT and return payload"""
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=['RS256'],
issuer=ISSUER,
options={'verify_aud': False} # We'll verify audience manually
)
verify_payload(payload)
return payload
except jwt.InvalidTokenError as e:
raise AuthorizationError(f'Invalid token: {str(e)}', 401)
except Exception as e:
raise AuthorizationError(f'Token validation failed: {str(e)}', 401)
def create_auth_info(payload: Dict[str, Any]) -> AuthInfo:
"""Create AuthInfo from JWT payload"""
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
audience = payload.get('aud', [])
if isinstance(audience, str):
audience = [audience]
return AuthInfo(
sub=payload.get('sub'),
client_id=payload.get('client_id'),
organization_id=payload.get('organization_id'),
scopes=scopes,
audience=audience
)
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verify payload based on permission model"""
# Implement your verification logic here based on permission model
# This will be shown in the permission models section below
pass
Then, implement the middleware to verify the access token:
- FastAPI
- Flask
- Django
- Django REST Framework
from fastapi import HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jwt_validator import validate_jwt, create_auth_info
security = HTTPBearer()
async def verify_access_token(credentials: HTTPAuthorizationCredentials = Depends(security)) -> AuthInfo:
try:
token = credentials.credentials
payload = validate_jwt(token)
return create_auth_info(payload)
except AuthorizationError as e:
raise HTTPException(status_code=e.status, detail=str(e))
from functools import wraps
from flask import request, jsonify, g
from jwt_validator import validate_jwt, create_auth_info
def verify_access_token(f):
@wraps(f)
def decorated_function(*args, **kwargs):
try:
token = extract_bearer_token_from_headers(dict(request.headers))
payload = validate_jwt(token)
# Store auth info in Flask's g object for generic use
g.auth = create_auth_info(payload)
return f(*args, **kwargs)
except AuthorizationError as e:
return jsonify({'error': str(e)}), e.status
return decorated_function
from django.http import JsonResponse
from jwt_validator import validate_jwt, create_auth_info
def require_access_token(view_func):
def wrapper(request, *args, **kwargs):
try:
headers = {key.replace('HTTP_', '').replace('_', '-').lower(): value
for key, value in request.META.items() if key.startswith('HTTP_')}
token = extract_bearer_token_from_headers(headers)
payload = validate_jwt(token)
# Attach auth info to request for generic use
request.auth = create_auth_info(payload)
return view_func(request, *args, **kwargs)
except AuthorizationError as e:
return JsonResponse({'error': str(e)}, status=e.status)
return wrapper
from rest_framework.authentication import TokenAuthentication
from rest_framework import exceptions
from jwt_validator import validate_jwt, create_auth_info
class AccessTokenAuthentication(TokenAuthentication):
keyword = 'Bearer' # Use 'Bearer' instead of 'Token'
def authenticate_credentials(self, key):
"""
Authenticate the token by validating it as a JWT.
"""
try:
payload = validate_jwt(key)
auth_info = create_auth_info(payload)
# Create a user-like object that holds auth info for generic use
user = type('User', (), {
'auth': auth_info,
'is_authenticated': True,
'is_anonymous': False,
'is_active': True,
})()
return (user, key)
except AuthorizationError as e:
if e.status == 401:
raise exceptions.AuthenticationFailed(str(e))
else: # 403
raise exceptions.PermissionDenied(str(e))
According to your permission model, implement the appropriate verification logic in jwt_validator.py
:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verify payload for global API resources"""
# Check audience claim matches your API resource indicator
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'https://your-api-resource-indicator' not in audiences:
raise AuthorizationError('Invalid audience')
# Check required scopes for global API resources
required_scopes = ['api:read', 'api:write'] # Replace with your actual required scopes
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
if not all(scope in scopes for scope in required_scopes):
raise AuthorizationError('Insufficient scope')
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verify payload for organization permissions"""
# Check audience claim matches organization format
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
has_org_audience = any(aud.startswith('urn:logto:organization:') for aud in audiences)
if not has_org_audience:
raise AuthorizationError('Invalid audience for organization permissions')
# Check organization ID matches the context (you may need to extract this from request context)
expected_org_id = 'your-organization-id' # Extract from request context
expected_aud = f'urn:logto:organization:{expected_org_id}'
if expected_aud not in audiences:
raise AuthorizationError('Organization ID mismatch')
# Check required organization scopes
required_scopes = ['invite:users', 'manage:settings'] # Replace with your actual required scopes
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
if not all(scope in scopes for scope in required_scopes):
raise AuthorizationError('Insufficient organization scope')
def verify_payload(payload: Dict[str, Any]) -> None:
"""Verify payload for organization-level API resources"""
# Check audience claim matches your API resource indicator
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'https://your-api-resource-indicator' not in audiences:
raise AuthorizationError('Invalid audience for organization-level API resources')
# Check organization ID matches the context (you may need to extract this from request context)
expected_org_id = 'your-organization-id' # Extract from request context
org_id = payload.get('organization_id')
if expected_org_id != org_id:
raise AuthorizationError('Organization ID mismatch')
# Check required scopes for organization-level API resources
required_scopes = ['api:read', 'api:write'] # Replace with your actual required scopes
scopes = payload.get('scope', '').split(' ') if payload.get('scope') else []
if not all(scope in scopes for scope in required_scopes):
raise AuthorizationError('Insufficient organization-level API scopes')
We use github.com/lestrrat-go/jwx to validate JWTs. Install it if you haven't already:
go mod init your-project
go get github.com/lestrrat-go/jwx/v3
First, add these shared components to your auth_middleware.go
:
import (
"context"
"strings"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
)
var jwkSet jwk.Set
func init() {
// Initialize JWKS cache
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
jwkSet, err = jwk.Fetch(ctx, JWKS_URI)
if err != nil {
panic("Failed to fetch JWKS: " + err.Error())
}
}
// validateJWT validates the JWT and returns the parsed token
func validateJWT(tokenString string) (jwt.Token, error) {
token, err := jwt.Parse([]byte(tokenString), jwt.WithKeySet(jwkSet))
if err != nil {
return nil, NewAuthorizationError("Invalid token: "+err.Error(), http.StatusUnauthorized)
}
// Verify issuer
if token.Issuer() != ISSUER {
return nil, NewAuthorizationError("Invalid issuer", http.StatusUnauthorized)
}
if err := verifyPayload(token); err != nil {
return nil, err
}
return token, nil
}
// Helper functions to extract token data
func getStringClaim(token jwt.Token, key string) string {
if val, ok := token.Get(key); ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
func getScopesFromToken(token jwt.Token) []string {
if val, ok := token.Get("scope"); ok {
if scope, ok := val.(string); ok && scope != "" {
return strings.Split(scope, " ")
}
}
return []string{}
}
func getAudienceFromToken(token jwt.Token) []string {
return token.Audience()
}
Then, implement the middleware to verify the access token:
- Gin
- Fiber
- Echo
- Chi
import "github.com/gin-gonic/gin"
func VerifyAccessToken() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, err := extractBearerTokenFromHeaders(c.Request)
if err != nil {
authErr := err.(*AuthorizationError)
c.JSON(authErr.Status, gin.H{"error": authErr.Message})
c.Abort()
return
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
c.JSON(authErr.Status, gin.H{"error": authErr.Message})
c.Abort()
return
}
// Store token in context for generic use
c.Set("auth", token)
c.Next()
}
}
import (
"net/http"
"github.com/gofiber/fiber/v2"
)
func VerifyAccessToken(c *fiber.Ctx) error {
// Convert fiber request to http.Request for compatibility
req := &http.Request{
Header: make(http.Header),
}
req.Header.Set("Authorization", c.Get("Authorization"))
tokenString, err := extractBearerTokenFromHeaders(req)
if err != nil {
authErr := err.(*AuthorizationError)
return c.Status(authErr.Status).JSON(fiber.Map{"error": authErr.Message})
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
return c.Status(authErr.Status).JSON(fiber.Map{"error": authErr.Message})
}
// Store token in locals for generic use
c.Locals("auth", token)
return c.Next()
}
import "github.com/labstack/echo/v4"
func VerifyAccessToken(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
tokenString, err := extractBearerTokenFromHeaders(c.Request())
if err != nil {
authErr := err.(*AuthorizationError)
return c.JSON(authErr.Status, echo.Map{"error": authErr.Message})
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
return c.JSON(authErr.Status, echo.Map{"error": authErr.Message})
}
// Store token in context for generic use
c.Set("auth", token)
return next(c)
}
}
import (
"context"
"encoding/json"
"net/http"
)
type contextKey string
const AuthContextKey contextKey = "auth"
func VerifyAccessToken(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenString, err := extractBearerTokenFromHeaders(r)
if err != nil {
authErr := err.(*AuthorizationError)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(authErr.Status)
json.NewEncoder(w).Encode(map[string]string{"error": authErr.Message})
return
}
token, err := validateJWT(tokenString)
if err != nil {
authErr := err.(*AuthorizationError)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(authErr.Status)
json.NewEncoder(w).Encode(map[string]string{"error": authErr.Message})
return
}
// Store token in context for generic use
ctx := context.WithValue(r.Context(), AuthContextKey, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
According to your permission model, you may need to adopt different verifyPayload
logic:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
func verifyPayload(token jwt.Token) error {
// Check audience claim matches your API resource indicator
if !hasAudience(token, "https://your-api-resource-indicator") {
return NewAuthorizationError("Invalid audience")
}
// Check required scopes for global API resources
requiredScopes := []string{"api:read", "api:write"} // Replace with your actual required scopes
if !hasRequiredScopes(token, requiredScopes) {
return NewAuthorizationError("Insufficient scope")
}
return nil
}
func verifyPayload(token jwt.Token) error {
// Check audience claim matches organization format
if !hasOrganizationAudience(token) {
return NewAuthorizationError("Invalid audience for organization permissions")
}
// Check organization ID matches the context (you may need to extract this from request context)
expectedOrgID := "your-organization-id" // Extract from request context
if !hasMatchingOrganization(token, expectedOrgID) {
return NewAuthorizationError("Organization ID mismatch")
}
// Check required organization scopes
requiredScopes := []string{"invite:users", "manage:settings"} // Replace with your actual required scopes
if !hasRequiredScopes(token, requiredScopes) {
return NewAuthorizationError("Insufficient organization scope")
}
return nil
}
func verifyPayload(token jwt.Token) error {
// Check audience claim matches your API resource indicator
if !hasAudience(token, "https://your-api-resource-indicator") {
return NewAuthorizationError("Invalid audience for organization-level API resources")
}
// Check organization ID matches the context (you may need to extract this from request context)
expectedOrgID := "your-organization-id" // Extract from request context
if !hasMatchingOrganizationID(token, expectedOrgID) {
return NewAuthorizationError("Organization ID mismatch")
}
// Check required scopes for organization-level API resources
requiredScopes := []string{"api:read", "api:write"} // Replace with your actual required scopes
if !hasRequiredScopes(token, requiredScopes) {
return NewAuthorizationError("Insufficient organization-level API scopes")
}
return nil
}
Add these helper functions for payload verification:
// hasAudience checks if the token has the specified audience
func hasAudience(token jwt.Token, expectedAud string) bool {
audiences := token.Audience()
for _, aud := range audiences {
if aud == expectedAud {
return true
}
}
return false
}
// hasOrganizationAudience checks if the token has organization audience format
func hasOrganizationAudience(token jwt.Token) bool {
audiences := token.Audience()
for _, aud := range audiences {
if strings.HasPrefix(aud, "urn:logto:organization:") {
return true
}
}
return false
}
// hasRequiredScopes checks if the token has all required scopes
func hasRequiredScopes(token jwt.Token, requiredScopes []string) bool {
scopes := getScopesFromToken(token)
for _, required := range requiredScopes {
found := false
for _, scope := range scopes {
if scope == required {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// hasMatchingOrganization checks if the token audience matches the expected organization
func hasMatchingOrganization(token jwt.Token, expectedOrgID string) bool {
expectedAud := fmt.Sprintf("urn:logto:organization:%s", expectedOrgID)
return hasAudience(token, expectedAud)
}
// hasMatchingOrganizationID checks if the token organization_id matches the expected one
func hasMatchingOrganizationID(token jwt.Token, expectedOrgID string) bool {
orgID := getStringClaim(token, "organization_id")
return orgID == expectedOrgID
}
We use different JWT libraries depending on the framework. Install the required dependencies:
- Spring Boot
- Quarkus
- Micronaut
- Vert.x Web
Add to your pom.xml
:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/protected/**").authenticated()
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
// Remember to set these environment variables in your deployment
String jwksUri = System.getenv("JWKS_URI");
String issuer = System.getenv("JWT_ISSUER");
return NimbusJwtDecoder.withJwkSetUri(jwksUri)
.issuer(issuer)
.build();
}
}
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class JwtValidator {
public void verifyPayload(Jwt jwt) {
// Issuer validation is handled automatically by Spring Security JWT decoder
// Implement your additional verification logic here based on permission model
// Use the helper methods below for claim extraction
// Example: throw new AuthorizationException("Insufficient permissions");
// The status code will be handled by Spring Security's exception handling
}
// Helper methods for Spring Boot JWT
private List<String> extractAudiences(Jwt jwt) {
return jwt.getAudience();
}
private String extractScopes(Jwt jwt) {
return jwt.getClaimAsString("scope");
}
private String extractOrganizationId(Jwt jwt) {
return jwt.getClaimAsString("organization_id");
}
}
Add to your pom.xml
:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId>
</dependency>
# JWT configuration
mp.jwt.verify.publickey.location=${JWKS_URI:https://your-tenant.logto.app/oidc/jwks}
mp.jwt.verify.issuer=${JWT_ISSUER:https://your-tenant.logto.app/oidc}
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import java.util.ArrayList;
import java.util.List;
@Provider
@ApplicationScoped
public class JwtVerificationFilter implements ContainerRequestFilter {
@Inject
JsonWebToken jwt;
@Override
public void filter(ContainerRequestContext requestContext) {
if (requestContext.getUriInfo().getPath().startsWith("/api/protected")) {
try {
verifyPayload(jwt);
requestContext.setProperty("auth", jwt);
} catch (AuthorizationException e) {
requestContext.abortWith(
Response.status(e.getStatusCode())
.entity("{\"error\": \"" + e.getMessage() + "\"}")
.build()
);
} catch (Exception e) {
requestContext.abortWith(
Response.status(401)
.entity("{\"error\": \"Invalid token\"}")
.build()
);
}
}
}
private void verifyPayload(JsonWebToken jwt) {
// Issuer validation is handled automatically by Quarkus JWT extension
// Implement your additional verification logic here based on permission model
// Use the helper methods below for claim extraction
}
// Helper methods for Quarkus JWT
private List<String> extractAudiences(JsonWebToken jwt) {
return new ArrayList<>(jwt.getAudience());
}
private String extractScopes(JsonWebToken jwt) {
return jwt.getClaim("scope");
}
private String extractOrganizationId(JsonWebToken jwt) {
return jwt.getClaim("organization_id");
}
}
Add to your pom.xml
:
<dependency>
<groupId>io.micronaut.security</groupId>
<artifactId>micronaut-security-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-http-server-netty</artifactId>
</dependency>
micronaut:
security:
authentication: bearer
token:
jwt:
signatures:
jwks:
logto:
url: ${JWKS_URI:https://your-tenant.logto.app/oidc/jwks}
claims-validators:
issuer: ${JWT_ISSUER:https://your-tenant.logto.app/oidc}
import io.micronaut.security.token.Claims;
import io.micronaut.security.token.validator.TokenValidator;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.List;
@Singleton
public class JwtClaimsValidator implements TokenValidator {
@Override
public Publisher<Boolean> validateToken(String token, Claims claims) {
try {
verifyPayload(claims);
return Mono.just(true);
} catch (AuthorizationException e) {
// Micronaut will handle the status code appropriately
return Mono.just(false);
}
}
private void verifyPayload(Claims claims) {
// Issuer validation is handled automatically by Micronaut JWT configuration
// Implement your additional verification logic here based on permission model
// Use the helper methods below for claim extraction
// Example: throw new AuthorizationException("Insufficient permissions");
}
// Helper methods for Micronaut JWT
@SuppressWarnings("unchecked")
private List<String> extractAudiences(Claims claims) {
Object aud = claims.get("aud");
if (aud instanceof List) {
return (List<String>) aud;
} else if (aud instanceof String) {
return Arrays.asList((String) aud);
}
return List.of();
}
private String extractScopes(Claims claims) {
return (String) claims.get("scope");
}
private String extractOrganizationId(Claims claims) {
return (String) claims.get("organization_id");
}
}
Add to your pom.xml
:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web-client</artifactId>
</dependency>
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.jwt.JWTAuth;
import io.vertx.ext.auth.jwt.JWTAuthOptions;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.WebClient;
import java.util.List;
import java.util.ArrayList;
public class JwtAuthHandler implements Handler<RoutingContext> {
private final JWTAuth jwtAuth;
private final WebClient webClient;
private final String expectedIssuer;
private final String jwksUri;
public JwtAuthHandler(Vertx vertx) {
this.webClient = WebClient.create(vertx);
this.jwtAuth = JWTAuth.create(vertx, new JWTAuthOptions());
// Remember to set these environment variables in your deployment
this.expectedIssuer = System.getenv("JWT_ISSUER");
this.jwksUri = System.getenv("JWKS_URI");
// Fetch JWKS and configure JWT auth
fetchJWKS().onSuccess(jwks -> {
// Configure JWKS (simplified - you may need a proper JWKS parser)
});
}
@Override
public void handle(RoutingContext context) {
String authHeader = context.request().getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Authorization header missing or invalid\"}");
return;
}
String token = authHeader.substring(7);
jwtAuth.authenticate(new JsonObject().put("jwt", token))
.onSuccess(user -> {
try {
JsonObject principal = user.principal();
verifyPayload(principal);
context.put("auth", principal);
context.next();
} catch (AuthorizationException e) {
context.response()
.setStatusCode(e.getStatusCode()) // Use the exception's status code
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"" + e.getMessage() + "\"}");
} catch (Exception e) {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Invalid token\"}");
}
})
.onFailure(err -> {
context.response()
.setStatusCode(401)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"Invalid token: " + err.getMessage() + "\"}");
});
}
private Future<JsonObject> fetchJWKS() {
return webClient.getAbs(this.jwksUri)
.send()
.map(response -> response.bodyAsJsonObject());
}
private void verifyPayload(JsonObject principal) {
// Verify issuer manually for Vert.x
String issuer = principal.getString("iss");
if (issuer == null || !expectedIssuer.equals(issuer)) {
throw new AuthorizationException("Invalid issuer: " + issuer);
}
// Implement your additional verification logic here based on permission model
// Use the helper methods below for claim extraction
}
// Helper methods for Vert.x JWT
private List<String> extractAudiences(JsonObject principal) {
JsonArray audiences = principal.getJsonArray("aud");
if (audiences != null) {
List<String> result = new ArrayList<>();
for (Object aud : audiences) {
result.add(aud.toString());
}
return result;
}
return List.of();
}
private String extractScopes(JsonObject principal) {
return principal.getString("scope");
}
private String extractOrganizationId(JsonObject principal) {
return principal.getString("organization_id");
}
}
According to your permission model, implement the appropriate verification logic:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
// Check audience claim matches your API resource indicator
List<String> audiences = extractAudiences(token); // Framework-specific extraction
if (!audiences.contains("https://your-api-resource-indicator")) {
throw new AuthorizationException("Invalid audience");
}
// Check required scopes for global API resources
List<String> requiredScopes = Arrays.asList("api:read", "api:write"); // Replace with your actual required scopes
String scopes = extractScopes(token); // Framework-specific extraction
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Insufficient scope");
}
// Check audience claim matches organization format
List<String> audiences = extractAudiences(token); // Framework-specific extraction
boolean hasOrgAudience = audiences.stream()
.anyMatch(aud -> aud.startsWith("urn:logto:organization:"));
if (!hasOrgAudience) {
throw new AuthorizationException("Invalid audience for organization permissions");
}
// Check organization ID matches the context (you may need to extract this from request context)
String expectedOrgId = "your-organization-id"; // Extract from request context
String expectedAud = "urn:logto:organization:" + expectedOrgId;
if (!audiences.contains(expectedAud)) {
throw new AuthorizationException("Organization ID mismatch");
}
// Check required organization scopes
List<String> requiredScopes = Arrays.asList("invite:users", "manage:settings"); // Replace with your actual required scopes
String scopes = extractScopes(token); // Framework-specific extraction
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Insufficient organization scope");
}
// Check audience claim matches your API resource indicator
List<String> audiences = extractAudiences(token); // Framework-specific extraction
if (!audiences.contains("https://your-api-resource-indicator")) {
throw new AuthorizationException("Invalid audience for organization-level API resources");
}
// Check organization ID matches the context (you may need to extract this from request context)
String expectedOrgId = "your-organization-id"; // Extract from request context
String orgId = extractOrganizationId(token); // Framework-specific extraction
if (!expectedOrgId.equals(orgId)) {
throw new AuthorizationException("Organization ID mismatch");
}
// Check required scopes for organization-level API resources
List<String> requiredScopes = Arrays.asList("api:read", "api:write"); // Replace with your actual required scopes
String scopes = extractScopes(token); // Framework-specific extraction
List<String> tokenScopes = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
if (!tokenScopes.containsAll(requiredScopes)) {
throw new AuthorizationException("Insufficient organization-level API scopes");
}
The helper methods for extracting claims are framework-specific. See the implementation details in the framework-specific validation files above.
Add the required NuGet package for JWT authentication:
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
Create a validation service to handle token validation:
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using YourApiNamespace.Exceptions;
namespace YourApiNamespace.Services
{
public interface IJwtValidationService
{
Task ValidateTokenAsync(TokenValidatedContext context);
}
public class JwtValidationService : IJwtValidationService
{
public async Task ValidateTokenAsync(TokenValidatedContext context)
{
var principal = context.Principal!;
try
{
// Add your validation logic here based on permission model
ValidatePayload(principal);
}
catch (AuthorizationException)
{
throw; // Re-throw authorization exceptions
}
catch (Exception ex)
{
throw new AuthorizationException($"Token validation failed: {ex.Message}", 401);
}
}
private void ValidatePayload(ClaimsPrincipal principal)
{
// Implement your verification logic here based on permission model
// This will be shown in the permission models section below
}
}
}
Configure JWT authentication in your Program.cs
:
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using YourApiNamespace.Services;
using YourApiNamespace.Exceptions;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddScoped<IJwtValidationService, JwtValidationService>();
// Configure JWT authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = AuthConstants.Issuer;
options.MetadataAddress = $"{AuthConstants.Issuer}/.well-known/openid_configuration";
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = AuthConstants.Issuer,
ValidateAudience = false, // We'll validate audience manually based on permission model
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(5)
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var validationService = context.HttpContext.RequestServices
.GetRequiredService<IJwtValidationService>();
await validationService.ValidateTokenAsync(context);
},
OnAuthenticationFailed = context =>
{
// Handle JWT library errors as 401
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
context.Response.WriteAsync($"{{\"error\": \"Invalid token\"}}");
context.HandleResponse();
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Global error handling for authentication/authorization failures
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (AuthorizationException ex)
{
context.Response.StatusCode = ex.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync($"{{\"error\": \"{ex.Message}\"}}");
}
});
// Configure the HTTP request pipeline
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
According to your permission model, implement the appropriate validation logic in JwtValidationService
:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
private void ValidatePayload(ClaimsPrincipal principal)
{
// Check audience claim matches your API resource indicator
var audiences = principal.FindAll("aud").Select(c => c.Value).ToList();
if (!audiences.Contains("https://your-api-resource-indicator"))
{
throw new AuthorizationException("Invalid audience");
}
// Check required scopes for global API resources
var requiredScopes = new[] { "api:read", "api:write" }; // Replace with your actual required scopes
var tokenScopes = principal.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!requiredScopes.All(scope => tokenScopes.Contains(scope)))
{
throw new AuthorizationException("Insufficient scope");
}
}
private void ValidatePayload(ClaimsPrincipal principal)
{
// Check audience claim matches organization format
var audiences = principal.FindAll("aud").Select(c => c.Value).ToList();
var hasOrgAudience = audiences.Any(aud => aud.StartsWith("urn:logto:organization:"));
if (!hasOrgAudience)
{
throw new AuthorizationException("Invalid audience for organization permissions");
}
// Check organization ID matches the context (you may need to extract this from request context)
var expectedOrgId = "your-organization-id"; // Extract from request context
var expectedAud = $"urn:logto:organization:{expectedOrgId}";
if (!audiences.Contains(expectedAud))
{
throw new AuthorizationException("Organization ID mismatch");
}
// Check required organization scopes
var requiredScopes = new[] { "invite:users", "manage:settings" }; // Replace with your actual required scopes
var tokenScopes = principal.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!requiredScopes.All(scope => tokenScopes.Contains(scope)))
{
throw new AuthorizationException("Insufficient organization scope");
}
}
private void ValidatePayload(ClaimsPrincipal principal)
{
// Check audience claim matches your API resource indicator
var audiences = principal.FindAll("aud").Select(c => c.Value).ToList();
if (!audiences.Contains("https://your-api-resource-indicator"))
{
throw new AuthorizationException("Invalid audience for organization-level API resources");
}
// Check organization ID matches the context (you may need to extract this from request context)
var expectedOrgId = "your-organization-id"; // Extract from request context
var orgId = principal.FindFirst("organization_id")?.Value;
if (!expectedOrgId.Equals(orgId))
{
throw new AuthorizationException("Organization ID mismatch");
}
// Check required scopes for organization-level API resources
var requiredScopes = new[] { "api:read", "api:write" }; // Replace with your actual required scopes
var tokenScopes = principal.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
if (!requiredScopes.All(scope => tokenScopes.Contains(scope)))
{
throw new AuthorizationException("Insufficient organization-level API scopes");
}
}
We use firebase/php-jwt to validate JWTs. Install it using Composer:
composer require firebase/php-jwt
First, add these shared utilities to handle JWT validation:
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
class JwtValidator
{
use AuthHelpers;
private static ?array $jwks = null;
public static function fetchJwks(): array
{
if (self::$jwks === null) {
$jwksData = file_get_contents(AuthConstants::JWKS_URI);
if ($jwksData === false) {
throw new AuthorizationException('Failed to fetch JWKS', 401);
}
self::$jwks = json_decode($jwksData, true);
}
return self::$jwks;
}
public static function validateJwt(string $token): array
{
try {
$jwks = self::fetchJwks();
$keys = JWK::parseKeySet($jwks);
$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;
// Verify issuer
if (($payload['iss'] ?? '') !== AuthConstants::ISSUER) {
throw new AuthorizationException('Invalid issuer', 401);
}
self::verifyPayload($payload);
return $payload;
} catch (AuthorizationException $e) {
throw $e;
} catch (Exception $e) {
throw new AuthorizationException('Invalid token: ' . $e->getMessage(), 401);
}
}
public static function createAuthInfo(array $payload): AuthInfo
{
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
$audience = $payload['aud'] ?? [];
if (is_string($audience)) {
$audience = [$audience];
}
return new AuthInfo(
sub: $payload['sub'],
clientId: $payload['client_id'] ?? null,
organizationId: $payload['organization_id'] ?? null,
scopes: $scopes,
audience: $audience
);
}
private static function verifyPayload(array $payload): void
{
// Implement your verification logic here based on permission model
// This will be shown in the permission models section below
}
}
Then, implement the middleware to verify the access token:
- Laravel
- Symfony
- Slim
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class VerifyAccessToken
{
use AuthHelpers;
public function handle(Request $request, Closure $next): Response
{
try {
$token = $this->extractBearerToken($request->headers->all());
$payload = JwtValidator::validateJwt($token);
// Store auth info in request attributes for generic use
$request->attributes->set('auth', JwtValidator::createAuthInfo($payload));
return $next($request);
} catch (AuthorizationException $e) {
return response()->json(['error' => $e->getMessage()], $e->statusCode);
}
}
}
Register the middleware in app/Http/Kernel.php
:
protected $middlewareAliases = [
// ... other middleware
'auth.token' => \App\Http\Middleware\VerifyAccessToken::class,
];
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class JwtAuthenticator extends AbstractAuthenticator
{
use AuthHelpers;
public function supports(Request $request): ?bool
{
return $request->headers->has('authorization');
}
public function authenticate(Request $request): Passport
{
try {
$token = $this->extractBearerToken($request->headers->all());
$payload = JwtValidator::validateJwt($token);
$authInfo = JwtValidator::createAuthInfo($payload);
// Store auth info in request attributes for generic use
$request->attributes->set('auth', $authInfo);
return new SelfValidatingPassport(new UserBadge($payload['sub']));
} catch (AuthorizationException $e) {
throw new AuthenticationException($e->getMessage());
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null; // Continue to the controller
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
}
}
Configure security in config/packages/security.yaml
:
security:
firewalls:
api:
pattern: ^/api/protected
stateless: true
custom_authenticators:
- App\Security\JwtAuthenticator
<?php
namespace App\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Slim\Psr7\Response;
class JwtMiddleware implements MiddlewareInterface
{
use AuthHelpers;
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$headers = $request->getHeaders();
$token = $this->extractBearerToken($headers);
$payload = JwtValidator::validateJwt($token);
// Store auth info in request attributes for generic use
$request = $request->withAttribute('auth', JwtValidator::createAuthInfo($payload));
return $handler->handle($request);
} catch (AuthorizationException $e) {
$response = new Response();
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus($e->statusCode);
}
}
}
According to your permission model, implement the appropriate verification logic in JwtValidator
:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
private static function verifyPayload(array $payload): void
{
// Check audience claim matches your API resource indicator
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience');
}
// Check required scopes for global API resources
$requiredScopes = ['api:read', 'api:write']; // Replace with your actual required scopes
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient scope');
}
}
}
private static function verifyPayload(array $payload): void
{
// Check audience claim matches organization format
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
$hasOrgAudience = false;
foreach ($audiences as $aud) {
if (str_starts_with($aud, 'urn:logto:organization:')) {
$hasOrgAudience = true;
break;
}
}
if (!$hasOrgAudience) {
throw new AuthorizationException('Invalid audience for organization permissions');
}
// Check organization ID matches the context (you may need to extract this from request context)
$expectedOrgId = 'your-organization-id'; // Extract from request context
$expectedAud = "urn:logto:organization:{$expectedOrgId}";
if (!in_array($expectedAud, $audiences)) {
throw new AuthorizationException('Organization ID mismatch');
}
// Check required organization scopes
$requiredScopes = ['invite:users', 'manage:settings']; // Replace with your actual required scopes
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient organization scope');
}
}
}
private static function verifyPayload(array $payload): void
{
// Check audience claim matches your API resource indicator
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}
if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience for organization-level API resources');
}
// Check organization ID matches the context (you may need to extract this from request context)
$expectedOrgId = 'your-organization-id'; // Extract from request context
$orgId = $payload['organization_id'] ?? null;
if ($expectedOrgId !== $orgId) {
throw new AuthorizationException('Organization ID mismatch');
}
// Check required scopes for organization-level API resources
$requiredScopes = ['api:read', 'api:write']; // Replace with your actual required scopes
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient organization-level API scopes');
}
}
}
We use the jwt gem to validate JWTs. Add it to your Gemfile:
gem 'jwt'
# net-http is part of Ruby standard library since Ruby 2.7, no need to add explicitly
Then run:
bundle install
First, add these shared utilities to handle JWKS and token validation:
require 'jwt'
require 'net/http'
require 'json'
class JwtValidator
include AuthHelpers
def self.fetch_jwks
@jwks ||= begin
uri = URI(AuthConstants::JWKS_URI)
response = Net::HTTP.get_response(uri)
raise AuthorizationError.new('Failed to fetch JWKS', 401) unless response.is_a?(Net::HTTPSuccess)
jwks_data = JSON.parse(response.body)
JWT::JWK::Set.new(jwks_data)
end
end
def self.validate_jwt(token)
jwks = fetch_jwks
# Let JWT library handle algorithm detection from JWKS
decoded_token = JWT.decode(token, nil, true, {
iss: AuthConstants::ISSUER,
verify_iss: true,
verify_aud: false, # We'll verify audience manually based on permission model
jwks: jwks
})[0]
verify_payload(decoded_token)
decoded_token
end
def self.create_auth_info(payload)
scopes = payload['scope']&.split(' ') || []
audience = payload['aud'] || []
AuthInfo.new(
payload['sub'],
payload['client_id'],
payload['organization_id'],
scopes,
audience
)
end
def self.verify_payload(payload)
# Implement your verification logic here based on permission model
# This will be shown in the permission models section below
end
end
Then, implement the middleware to verify the access token:
- Ruby on Rails
- Sinatra
- Grape
module JwtAuthentication
extend ActiveSupport::Concern
include AuthHelpers
included do
before_action :verify_access_token, only: [:protected_action] # Add specific actions
end
private
def verify_access_token
begin
token = extract_bearer_token(request)
decoded_token = JwtValidator.validate_jwt(token)
# Store auth info for generic use
@auth = JwtValidator.create_auth_info(decoded_token)
rescue AuthorizationError => e
render json: { error: e.message }, status: e.status
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
render json: { error: 'Invalid token' }, status: 401
end
end
end
class AuthMiddleware
include AuthHelpers
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
# Only protect specific routes
if request.path.start_with?('/api/protected')
begin
token = extract_bearer_token(request)
decoded_token = JwtValidator.validate_jwt(token)
# Store auth info in env for generic use
env['auth'] = JwtValidator.create_auth_info(decoded_token)
rescue AuthorizationError => e
return [e.status, { 'Content-Type' => 'application/json' }, [{ error: e.message }.to_json]]
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
return [401, { 'Content-Type' => 'application/json' }, [{ error: 'Invalid token' }.to_json]]
end
end
@app.call(env)
end
end
module GrapeAuthHelpers
include AuthHelpers
def authenticate_user!
begin
token = extract_bearer_token(request)
decoded_token = JwtValidator.validate_jwt(token)
# Store auth info for generic use
@auth = JwtValidator.create_auth_info(decoded_token)
rescue AuthorizationError => e
error!({ error: e.message }, e.status)
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature => e
error!({ error: 'Invalid token' }, 401)
end
end
def auth
@auth
end
end
According to your permission model, implement the appropriate verification logic in JwtValidator
:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
def self.verify_payload(payload)
# Check audience claim matches your API resource indicator
audiences = payload['aud'] || []
unless audiences.include?('https://your-api-resource-indicator')
raise AuthorizationError.new('Invalid audience')
end
# Check required scopes for global API resources
required_scopes = ['api:read', 'api:write'] # Replace with your actual required scopes
token_scopes = payload['scope']&.split(' ') || []
unless required_scopes.all? { |scope| token_scopes.include?(scope) }
raise AuthorizationError.new('Insufficient scope')
end
end
def self.verify_payload(payload)
# Check audience claim matches organization format
audiences = payload['aud'] || []
has_org_audience = audiences.any? { |aud| aud.start_with?('urn:logto:organization:') }
unless has_org_audience
raise AuthorizationError.new('Invalid audience for organization permissions')
end
# Check organization ID matches the context (you may need to extract this from request context)
expected_org_id = 'your-organization-id' # Extract from request context
expected_aud = "urn:logto:organization:#{expected_org_id}"
unless audiences.include?(expected_aud)
raise AuthorizationError.new('Organization ID mismatch')
end
# Check required organization scopes
required_scopes = ['invite:users', 'manage:settings'] # Replace with your actual required scopes
token_scopes = payload['scope']&.split(' ') || []
unless required_scopes.all? { |scope| token_scopes.include?(scope) }
raise AuthorizationError.new('Insufficient organization scope')
end
end
def self.verify_payload(payload)
# Check audience claim matches your API resource indicator
audiences = payload['aud'] || []
unless audiences.include?('https://your-api-resource-indicator')
raise AuthorizationError.new('Invalid audience for organization-level API resources')
end
# Check organization ID matches the context (you may need to extract this from request context)
expected_org_id = 'your-organization-id' # Extract from request context
org_id = payload['organization_id']
unless expected_org_id == org_id
raise AuthorizationError.new('Organization ID mismatch')
end
# Check required scopes for organization-level API resources
required_scopes = ['api:read', 'api:write'] # Replace with your actual required scopes
token_scopes = payload['scope']&.split(' ') || []
unless required_scopes.all? { |scope| token_scopes.include?(scope) }
raise AuthorizationError.new('Insufficient organization-level API scopes')
end
end
We use jsonwebtoken to validate JWTs. Add the required dependencies to your Cargo.toml
:
[dependencies]
jsonwebtoken = "9.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }
First, add these shared utilities to handle JWT validation:
use crate::{AuthInfo, AuthorizationError, ISSUER, JWKS_URI};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde_json::Value;
use std::collections::HashMap;
pub struct JwtValidator {
jwks: HashMap<String, DecodingKey>,
}
impl JwtValidator {
pub async fn new() -> Result<Self, AuthorizationError> {
let jwks = Self::fetch_jwks().await?;
Ok(Self { jwks })
}
async fn fetch_jwks() -> Result<HashMap<String, DecodingKey>, AuthorizationError> {
let response = reqwest::get(JWKS_URI).await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to fetch JWKS: {}", e), 401)
})?;
let jwks: Value = response.json().await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to parse JWKS: {}", e), 401)
})?;
let mut keys = HashMap::new();
if let Some(keys_array) = jwks["keys"].as_array() {
for key in keys_array {
if let (Some(kid), Some(kty), Some(n), Some(e)) = (
key["kid"].as_str(),
key["kty"].as_str(),
key["n"].as_str(),
key["e"].as_str(),
) {
if kty == "RSA" {
if let Ok(decoding_key) = DecodingKey::from_rsa_components(n, e) {
keys.insert(kid.to_string(), decoding_key);
}
}
}
}
}
if keys.is_empty() {
return Err(AuthorizationError::with_status("No valid keys found in JWKS", 401));
}
Ok(keys)
}
pub fn validate_jwt(&self, token: &str) -> Result<AuthInfo, AuthorizationError> {
let header = decode_header(token).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token header: {}", e), 401)
})?;
let kid = header.kid.ok_or_else(|| {
AuthorizationError::with_status("Token missing kid claim", 401)
})?;
let key = self.jwks.get(&kid).ok_or_else(|| {
AuthorizationError::with_status("Unknown key ID", 401)
})?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[ISSUER]);
validation.validate_aud = false; // We'll verify audience manually
let token_data = decode::<Value>(token, key, &validation).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token: {}", e), 401)
})?;
let claims = token_data.claims;
self.verify_payload(&claims)?;
Ok(self.create_auth_info(claims))
}
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Implement your verification logic here based on permission model
// This will be shown in the permission models section below
Ok(())
}
fn create_auth_info(&self, claims: Value) -> AuthInfo {
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').map(|s| s.to_string()).collect())
.unwrap_or_default();
let audience = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),
Value::String(s) => vec![s.clone()],
_ => vec![],
};
AuthInfo::new(
claims["sub"].as_str().unwrap_or_default().to_string(),
claims["client_id"].as_str().map(|s| s.to_string()),
claims["organization_id"].as_str().map(|s| s.to_string()),
scopes,
audience,
)
}
}
Then, implement the middleware to verify the access token:
- Axum
- Actix Web
- Rocket
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Extension, Json,
};
use serde_json::json;
use std::sync::Arc;
pub async fn jwt_middleware(
Extension(validator): Extension<Arc<JwtValidator>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthorizationError> {
let authorization = headers
.get("authorization")
.and_then(|h| h.to_str().ok());
let token = extract_bearer_token(authorization)?;
let auth_info = validator.validate_jwt(token)?;
// Store auth info in request extensions for generic use
request.extensions_mut().insert(auth_info);
Ok(next.run(request).await)
}
impl IntoResponse for AuthorizationError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::FORBIDDEN);
(status, Json(json!({ "error": self.message }))).into_response()
}
}
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
web, Error, HttpMessage, HttpResponse,
};
use futures::future::{ok, Ready};
use std::sync::Arc;
pub struct JwtMiddleware {
validator: Arc<JwtValidator>,
}
impl JwtMiddleware {
pub fn new(validator: Arc<JwtValidator>) -> Self {
Self { validator }
}
}
impl<S, B> Transform<S, ServiceRequest> for JwtMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = JwtMiddlewareService<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(JwtMiddlewareService {
service,
validator: self.validator.clone(),
})
}
}
pub struct JwtMiddlewareService<S> {
service: S,
validator: Arc<JwtValidator>,
}
impl<S, B> Service<ServiceRequest> for JwtMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = futures::future::LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let validator = self.validator.clone();
Box::pin(async move {
let authorization = req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok());
match extract_bearer_token(authorization)
.and_then(|token| validator.validate_jwt(token))
{
Ok(auth_info) => {
// Store auth info in request extensions for generic use
req.extensions_mut().insert(auth_info);
let fut = self.service.call(req);
fut.await
}
Err(e) => {
let response = HttpResponse::build(
actix_web::http::StatusCode::from_u16(e.status_code)
.unwrap_or(actix_web::http::StatusCode::FORBIDDEN),
)
.json(serde_json::json!({ "error": e.message }));
Ok(req.into_response(response))
}
}
})
}
}
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use rocket::{
http::Status,
outcome::Outcome,
request::{self, FromRequest, Request},
State,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AuthInfo {
type Error = AuthorizationError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let validator = match req.guard::<&State<JwtValidator>>().await {
Outcome::Success(validator) => validator,
Outcome::Failure((status, _)) => {
return Outcome::Failure((
status,
AuthorizationError::with_status("JWT validator not found", 500),
))
}
Outcome::Forward(()) => {
return Outcome::Forward(())
}
};
let authorization = req.headers().get_one("authorization");
match extract_bearer_token(authorization)
.and_then(|token| validator.validate_jwt(token))
{
Ok(auth_info) => Outcome::Success(auth_info),
Err(e) => {
let status = Status::from_code(e.status_code).unwrap_or(Status::Forbidden);
Outcome::Failure((status, e))
}
}
}
}
According to your permission model, implement the appropriate verification logic in JwtValidator
:
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Check audience claim matches your API resource indicator
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};
if !audiences.contains(&"https://your-api-resource-indicator") {
return Err(AuthorizationError::new("Invalid audience"));
}
// Check required scopes for global API resources
let required_scopes = vec!["api:read", "api:write"]; // Replace with your actual required scopes
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();
for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient scope"));
}
}
Ok(())
}
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Check audience claim matches organization format
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};
let has_org_audience = audiences.iter().any(|aud| aud.starts_with("urn:logto:organization:"));
if !has_org_audience {
return Err(AuthorizationError::new("Invalid audience for organization permissions"));
}
// Check organization ID matches the context (you may need to extract this from request context)
let expected_org_id = "your-organization-id"; // Extract from request context
let expected_aud = format!("urn:logto:organization:{}", expected_org_id);
if !audiences.contains(&expected_aud.as_str()) {
return Err(AuthorizationError::new("Organization ID mismatch"));
}
// Check required organization scopes
let required_scopes = vec!["invite:users", "manage:settings"]; // Replace with your actual required scopes
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();
for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient organization scope"));
}
}
Ok(())
}
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Check audience claim matches your API resource indicator
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};
if !audiences.contains(&"https://your-api-resource-indicator") {
return Err(AuthorizationError::new("Invalid audience for organization-level API resources"));
}
// Check organization ID matches the context (you may need to extract this from request context)
let expected_org_id = "your-organization-id"; // Extract from request context
let org_id = claims["organization_id"].as_str().unwrap_or_default();
if expected_org_id != org_id {
return Err(AuthorizationError::new("Organization ID mismatch"));
}
// Check required scopes for organization-level API resources
let required_scopes = vec!["api:read", "api:write"]; // Replace with your actual required scopes
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();
for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient organization-level API scopes"));
}
}
Ok(())
}
Step 4: Apply middleware to your API
Apply the middleware your protected API routes.
- Node.js
- Python
- Go
- Java
- .NET
- PHP
- Ruby
- Rust
- Express.js
- Koa.js
- Fastify
- Hapi.js
- NestJS
import { verifyAccessToken } from './auth-middleware.js';
app.get('/api/protected', verifyAccessToken, (req, res) => {
// Access auth information directly from req.auth
res.json({ auth: req.auth });
});
import Router from '@koa/router';
import { koaVerifyAccessToken } from './auth-middleware.js';
const router = new Router();
router.get('/api/protected', koaVerifyAccessToken, (ctx) => {
// Access auth information directly from ctx.state.auth
ctx.body = { auth: ctx.state.auth };
});
app.use(router.routes());
import { fastifyVerifyAccessToken } from './auth-middleware.js';
server.get('/api/protected', { preHandler: fastifyVerifyAccessToken }, (request, reply) => {
// Access auth information directly from request.auth
reply.send({ auth: request.auth });
});
import { hapiVerifyAccessToken } from './auth-middleware.js';
server.route({
method: 'GET',
path: '/api/protected',
options: {
pre: [{ method: hapiVerifyAccessToken }],
handler: (request, h) => {
// Access auth information from request.app.auth
return { auth: request.app.auth };
},
},
});
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AccessTokenGuard } from './access-token.guard.js';
@Controller('api')
export class ProtectedController {
@Get('protected')
@UseGuards(AccessTokenGuard)
getProtected(@Req() req: any) {
// Access auth information from req.auth
return { auth: req.auth };
}
}
- FastAPI
- Flask
- Django
- Django REST Framework
from fastapi import FastAPI, Depends
from auth_middleware import verify_access_token, AuthInfo
app = FastAPI()
@app.get("/api/protected")
async def protected_endpoint(auth: AuthInfo = Depends(verify_access_token)):
# Access auth information directly from auth parameter
return {"auth": auth.to_dict()}
from flask import Flask, g, jsonify
from auth_middleware import verify_access_token
app = Flask(__name__)
@app.route('/api/protected', methods=['GET'])
@verify_access_token
def protected_endpoint():
# Access auth information from g.auth
return jsonify({"auth": g.auth.to_dict()})
from django.http import JsonResponse
from auth_middleware import require_access_token
@require_access_token
def protected_view(request):
# Access auth information from request.auth
return JsonResponse({"auth": request.auth.to_dict()})
from django.urls import path
from . import views
urlpatterns = [
path('api/protected/', views.protected_view, name='protected'),
]
from rest_framework.decorators import api_view, authentication_classes
from rest_framework.response import Response
from auth_middleware import AccessTokenAuthentication
@api_view(['GET'])
@authentication_classes([AccessTokenAuthentication])
def protected_view(request):
# Access auth information from request.user.auth
return Response({"auth": request.user.auth.to_dict()})
Or using class-based views:
from rest_framework.views import APIView
from rest_framework.response import Response
from auth_middleware import AccessTokenAuthentication
class ProtectedView(APIView):
authentication_classes = [AccessTokenAuthentication]
def get(self, request):
# Access auth information from request.user.auth
return Response({"auth": request.user.auth.to_dict()})
from django.urls import path
from . import views
urlpatterns = [
path('api/protected/', views.protected_view, name='protected'),
# Or for class-based views:
# path('api/protected/', views.ProtectedView.as_view(), name='protected'),
]
- Gin
- Echo
- Fiber
- Chi
package main
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
r := gin.Default()
// Apply middleware to protected routes
r.GET("/api/protected", VerifyAccessToken(), func(c *gin.Context) {
// Access token information directly from context
tokenInterface, exists := c.Get("auth")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token not found"})
return
}
token := tokenInterface.(jwt.Token)
c.JSON(http.StatusOK, gin.H{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
})
r.Run(":8080")
}
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
e := echo.New()
// Apply middleware to protected routes
e.GET("/api/protected", func(c echo.Context) error {
// Access token information directly from context
tokenInterface := c.Get("auth")
if tokenInterface == nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "Token not found"})
}
token := tokenInterface.(jwt.Token)
return c.JSON(http.StatusOK, echo.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
}, VerifyAccessToken)
e.Start(":8080")
}
Or using route groups:
package main
import (
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
e := echo.New()
// Create protected route group
api := e.Group("/api", VerifyAccessToken)
api.GET("/protected", func(c echo.Context) error {
// Access token information directly from context
token := c.Get("auth").(jwt.Token)
return c.JSON(200, echo.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
"message": "Protected data accessed successfully",
})
})
e.Start(":8080")
}
package main
import (
"github.com/gofiber/fiber/v2"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
app := fiber.New()
// Apply middleware to protected routes
app.Get("/api/protected", VerifyAccessToken, func(c *fiber.Ctx) error {
// Access token information directly from locals
tokenInterface := c.Locals("auth")
if tokenInterface == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Token not found"})
}
token := tokenInterface.(jwt.Token)
return c.JSON(fiber.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
})
app.Listen(":8080")
}
Or using route groups:
package main
import (
"github.com/gofiber/fiber/v2"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
app := fiber.New()
// Create protected route group
api := app.Group("/api", VerifyAccessToken)
api.Get("/protected", func(c *fiber.Ctx) error {
// Access token information directly from locals
token := c.Locals("auth").(jwt.Token)
return c.JSON(fiber.Map{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
"message": "Protected data accessed successfully",
})
})
app.Listen(":8080")
}
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
r := chi.NewRouter()
// Apply middleware to protected routes
r.With(VerifyAccessToken).Get("/api/protected", func(w http.ResponseWriter, r *http.Request) {
// Access token information directly from context
tokenInterface := r.Context().Value(AuthContextKey)
if tokenInterface == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Token not found"})
return
}
token := tokenInterface.(jwt.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
})
})
http.ListenAndServe(":8080", r)
}
Or using route groups:
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func main() {
r := chi.NewRouter()
// Create protected route group
r.Route("/api", func(r chi.Router) {
r.Use(VerifyAccessToken)
r.Get("/protected", func(w http.ResponseWriter, r *http.Request) {
// Access token information directly from context
token := r.Context().Value(AuthContextKey).(jwt.Token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sub": token.Subject(),
"client_id": getStringClaim(token, "client_id"),
"organization_id": getStringClaim(token, "organization_id"),
"scopes": getScopesFromToken(token),
"audience": getAudienceFromToken(token),
"message": "Protected data accessed successfully",
})
})
})
http.ListenAndServe(":8080", r)
}
- Spring Boot
- Quarkus
- Micronaut
- Vert.x Web
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@RestController
public class ProtectedController {
@GetMapping("/api/protected")
public Map<String, Object> protectedEndpoint(@AuthenticationPrincipal Jwt jwt) {
// Access token information directly from JWT
String scopes = jwt.getClaimAsString("scope");
List<String> scopeList = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
return Map.of(
"sub", jwt.getSubject(),
"client_id", jwt.getClaimAsString("client_id"),
"organization_id", jwt.getClaimAsString("organization_id"),
"scopes", scopeList,
"audience", jwt.getAudience()
);
}
}
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.container.ContainerRequestContext;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Path("/api")
public class ProtectedResource {
@Inject
JsonWebToken jwt;
@GET
@Path("/protected")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> protectedEndpoint(@Context ContainerRequestContext requestContext) {
// Access JWT directly from injection or context
JsonWebToken token = (JsonWebToken) requestContext.getProperty("auth");
if (token == null) {
token = jwt; // Fallback to injected JWT
}
String scopes = token.getClaim("scope");
List<String> scopeList = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
return Map.of(
"sub", token.getSubject(),
"client_id", token.<String>getClaim("client_id"),
"organization_id", token.<String>getClaim("organization_id"),
"scopes", scopeList,
"audience", token.getAudience()
);
}
}
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Controller("/api")
@Secured(SecurityRule.IS_AUTHENTICATED)
public class ProtectedController {
@Get("/protected")
public Map<String, Object> protectedEndpoint(Authentication authentication) {
// Access token information directly from Authentication
String scopes = (String) authentication.getAttributes().get("scope");
List<String> scopeList = scopes != null ? Arrays.asList(scopes.split(" ")) : List.of();
return Map.of(
"sub", authentication.getName(),
"client_id", authentication.getAttributes().get("client_id"),
"organization_id", authentication.getAttributes().get("organization_id"),
"scopes", scopeList,
"audience", authentication.getAttributes().get("aud")
);
}
}
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
public class MainVerticle extends AbstractVerticle {
@Override
public void start(Promise<Void> startPromise) throws Exception {
Router router = Router.router(vertx);
// Apply middleware to protected routes
router.route("/api/protected*").handler(new JwtAuthHandler(vertx));
router.get("/api/protected").handler(this::protectedEndpoint);
vertx.createHttpServer()
.requestHandler(router)
.listen(8080, result -> {
if (result.succeeded()) {
startPromise.complete();
} else {
startPromise.fail(result.cause());
}
});
}
private void protectedEndpoint(RoutingContext context) {
// Access JWT principal directly from context
JsonObject principal = context.get("auth");
if (principal == null) {
context.response()
.setStatusCode(500)
.putHeader("Content-Type", "application/json")
.end("{\"error\": \"JWT principal not found\"}");
return;
}
String scopes = principal.getString("scope");
JsonObject response = new JsonObject()
.put("sub", principal.getString("sub"))
.put("client_id", principal.getString("client_id"))
.put("organization_id", principal.getString("organization_id"))
.put("scopes", scopes != null ? scopes.split(" ") : new String[0])
.put("audience", principal.getJsonArray("aud"));
context.response()
.putHeader("Content-Type", "application/json")
.end(response.encode());
}
}
We've already set up the authentication and authorization middleware in the previous sections. Now we can create a protected controller that validates access tokens and extracts claims from authenticated requests.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace YourApiNamespace.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize] // Require authentication for all actions in this controller
public class ProtectedController : ControllerBase
{
[HttpGet]
public IActionResult GetProtectedData()
{
// Access token information directly from User claims
var sub = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value;
var clientId = User.FindFirst("client_id")?.Value;
var organizationId = User.FindFirst("organization_id")?.Value;
var scopes = User.FindFirst("scope")?.Value?.Split(' ') ?? Array.Empty<string>();
var audience = User.FindAll("aud").Select(c => c.Value).ToArray();
return Ok(new {
sub,
client_id = clientId,
organization_id = organizationId,
scopes,
audience
});
}
[HttpGet("claims")]
public IActionResult GetAllClaims()
{
// Return all claims for debugging/inspection
var claims = User.Claims.Select(c => new { c.Type, c.Value }).ToList();
return Ok(new { claims });
}
}
}
- Laravel
- Symfony
- Slim
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth.token')->group(function () {
Route::get('/api/protected', function (Request $request) {
// Access auth information from request attributes
$auth = $request->attributes->get('auth');
return ['auth' => $auth->toArray()];
});
});
Or using controllers:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ProtectedController extends Controller
{
public function __construct()
{
$this->middleware('auth.token');
}
public function index(Request $request)
{
// Access auth information from request attributes
$auth = $request->attributes->get('auth');
return ['auth' => $auth->toArray()];
}
public function show(Request $request)
{
// Your protected endpoint logic
$auth = $request->attributes->get('auth');
return [
'auth' => $auth->toArray(),
'message' => 'Protected data accessed successfully'
];
}
}
<?php
namespace App\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/protected')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ProtectedController extends AbstractController
{
#[Route('', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
// Access auth information from request attributes
$auth = $request->attributes->get('auth');
return $this->json(['auth' => $auth->toArray()]);
}
}
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class ProtectedController
{
public function index(Request $request, Response $response): Response
{
// Access auth information from request attributes
$auth = $request->getAttribute('auth');
$response->getBody()->write(json_encode(['auth' => $auth->toArray()]));
return $response->withHeader('Content-Type', 'application/json');
}
public function detailed(Request $request, Response $response): Response
{
// Your protected endpoint logic
$auth = $request->getAttribute('auth');
$data = [
'auth' => $auth->toArray(),
'message' => 'Protected data accessed successfully'
];
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
}
}
- Ruby on Rails
- Sinatra
- Grape
class ApplicationController < ActionController::API # For API-only apps
# class ApplicationController < ActionController::Base # For full Rails apps
include JwtAuthentication
end
class Api::ProtectedController < ApplicationController
before_action :verify_access_token
def index
# Access auth information from @auth
render json: { auth: @auth.to_h }
end
end
Rails.application.routes.draw do
namespace :api do
resources :protected, only: [:index]
end
end
require 'sinatra'
require 'json'
require_relative 'auth_middleware'
require_relative 'auth_constants'
require_relative 'auth_info'
require_relative 'authorization_error'
require_relative 'auth_helpers'
require_relative 'jwt_validator'
# Apply middleware
use AuthMiddleware
get '/api/protected' do
content_type :json
# Access auth information from env
auth = env['auth']
{ auth: auth.to_h }.to_json
end
# Public endpoint (not protected by middleware)
get '/' do
content_type :json
{ message: "Public endpoint" }.to_json
end
require 'grape'
require_relative 'auth_helpers'
require_relative 'auth_constants'
require_relative 'auth_info'
require_relative 'authorization_error'
require_relative 'jwt_validator'
class API < Grape::API
format :json
helpers GrapeAuthHelpers
namespace :api do
namespace :protected do
before do
authenticate_user!
end
get do
# Access auth information from auth helper
{ auth: auth.to_h }
end
end
end
# Public endpoint (not protected)
get :public do
{ message: "Public endpoint" }
end
end
require_relative 'api'
run API
- Axum
- Actix Web
- Rocket
use axum::{
extract::Extension,
http::StatusCode,
middleware,
response::Json,
routing::get,
Router,
};
use serde_json::{json, Value};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
mod lib;
mod jwt_validator;
mod middleware as jwt_middleware;
use lib::AuthInfo;
use jwt_validator::JwtValidator;
#[tokio::main]
async fn main() {
let validator = Arc::new(JwtValidator::new().await.expect("Failed to initialize JWT validator"));
let app = Router::new()
.route("/api/protected", get(protected_handler))
.layer(middleware::from_fn(jwt_middleware::jwt_middleware))
.layer(Extension(validator))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn protected_handler(Extension(auth): Extension<AuthInfo>) -> Json<Value> {
// Access auth information directly from Extension
Json(json!({ "auth": auth }))
}
use actix_web::{middleware::Logger, web, App, HttpRequest, HttpServer, Result};
use serde_json::{json, Value};
use std::sync::Arc;
mod lib;
mod jwt_validator;
mod middleware as jwt_middleware;
use lib::AuthInfo;
use jwt_validator::JwtValidator;
use jwt_middleware::JwtMiddleware;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let validator = Arc::new(JwtValidator::new().await.expect("Failed to initialize JWT validator"));
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(validator.clone()))
.wrap(Logger::default())
.service(
web::scope("/api/protected")
.wrap(JwtMiddleware::new(validator.clone()))
.route("", web::get().to(protected_handler))
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}
async fn protected_handler(req: HttpRequest) -> Result<web::Json<Value>> {
// Access auth information from request extensions
let auth = req.extensions().get::<AuthInfo>().unwrap();
Ok(web::Json(json!({ "auth": auth })))
}
use rocket::{get, launch, routes, serde::json::Json};
use serde_json::{json, Value};
mod lib;
mod jwt_validator;
mod guards;
use lib::AuthInfo;
use jwt_validator::JwtValidator;
#[get("/api/protected")]
fn protected_handler(auth: AuthInfo) -> Json<Value> {
// Access auth information directly from request guard
Json(json!({ "auth": auth }))
}
#[launch]
async fn rocket() -> _ {
let validator = JwtValidator::new().await.expect("Failed to initialize JWT validator");
rocket::build()
.manage(validator)
.mount("/", routes![protected_handler])
}
Step 5: Test your implementation
Get access tokens
From your client application: If you've set up a client integration, your app can obtain tokens automatically. Extract the access token and use it in API requests.
For testing with curl/Postman:
-
User tokens: Use your client app's developer tools to copy the access token from localStorage or the network tab
-
Machine-to-machine tokens: Use the client credentials flow. Here's a non-normative example using curl:
curl -X POST https://your-tenant.logto.app/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=your-m2m-client-id" \
-d "client_secret=your-m2m-client-secret" \
-d "resource=https://your-api-resource-indicator" \
-d "scope=api:read api:write"You may need to adjust the
resource
andscope
parameters based on your API resource and permissions; anorganization_id
parameter may also be required if your API is organization-scoped.
Need to inspect the token contents? Use our JWT decoder to decode and verify your JWTs.
Test protected endpoints
Valid token request
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected
Expected response:
{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
Missing token
curl http://localhost:3000/api/protected
Expected response (401):
{
"error": "Authorization header is missing"
}
Invalid token
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected
Expected response (401):
{
"error": "Invalid token"
}
Permission model-specific testing
- Global API resources
- Organization (non-API) permissions
- Organization-level API resources
Test scenarios for APIs protected with global scopes:
- Valid scopes: Test with tokens that include your required API scopes (e.g.,
api:read
,api:write
) - Missing scopes: Expect 403 Forbidden when token lacks required scopes
- Wrong audience: Expect 403 Forbidden when audience does not match the API resource
# Token with missing scopes - expect 403
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected
Test scenarios for organization-specific access control:
- Valid organization token: Test with tokens that include correct organization context (organization ID and scopes)
- Missing scopes: Expect 403 Forbidden when user doesn't have permissions for the requested action
- Wrong organization: Expect 403 Forbidden when audience does not match the organization context (
urn:logto:organization:<organization_id>
)
# Token for wrong organization - expect 403
curl -H "Authorization: Bearer token-for-different-organization" \
http://localhost:3000/api/protected
Test scenarios combining API resource validation with organization context:
- Valid organization + API scopes: Test with tokens that have both organization context and required API scopes
- Missing API scopes: Expect 403 Forbidden when organization token lacks required API permissions
- Wrong organization: Expect 403 Forbidden when accessing API with token from different organization
- Wrong audience: Expect 403 Forbidden when audience does not match the organization-level API resource
# Organization token without API scopes - expect 403
curl -H "Authorization: Bearer organization-token-without-api-scopes" \
http://localhost:3000/api/protected
Related resources
Customizing token claims JSON Web Token (JWT)OpenID Connect Discovery
RFC 8707: Resource Indicators