Skip to main content

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

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

auth-middleware.ts
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);
}

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 SettingsDomains.
  • In any application settings where you configured in Logto, SettingsEndpoints & 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"
}

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:

Permission modelAudience claim (aud)Organization claim (organization_id)Scopes (permissions) to check (scope)
Global API resourcesAPI resource indicatorNot presentAPI resource permissions
Organization (non-API) permissionsurn:logto:organization:<id> (organization context is in aud claim)Not presentOrganization permissions
Organization-level API resourcesAPI resource indicatorOrganization ID (must match request)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.

tip:

Always validate both permissions (scopes) and context (audience, organization) for secure multi-tenant APIs.

Add the validation logic

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:

jwt-validator.ts
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
import { AuthInfo, AuthorizationError } from './auth-middleware.js';

const jwks = createRemoteJWKSet(new URL(JWKS_URI));

export async function validateJwtToken(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:

auth-middleware.ts
import { Request, Response, NextFunction } from 'express';
import { extractBearerTokenFromHeaders, AuthorizationError } from './auth-middleware.js';
import { validateJwtToken, 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 validateJwtToken(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 });
}
}

According to your permission model, implement the appropriate verification logic in jwt-validator.ts:

jwt-validator.ts
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');
}
}

Step 4: Apply middleware to your API

Apply the middleware your protected API routes.

app.ts
import express from 'express';
import { verifyAccessToken } from './auth-middleware.js';

const app = express();

app.get('/api/protected', verifyAccessToken, (req, res) => {
// Access auth information directly from req.auth
res.json({ auth: req.auth });
});

app.get('/api/protected/detailed', verifyAccessToken, (req, res) => {
// Your protected endpoint logic
res.json({
auth: req.auth,
message: 'Protected data accessed successfully',
});
});

app.listen(3000);

Step 5: Test your implementation

Test your API with valid and invalid tokens to ensure:

  • Valid tokens pass through and provide access.
  • Return 401 Unauthorized for invalid/missing tokens. Return 403 Forbidden for valid tokens that lack required permissions or context.
Customizing token claims JSON Web Token (JWT)

OpenID Connect Discovery

RFC 8707: Resource Indicators