How to implement a custom authorizer?

Implementing a custom authorizer allows you to enforce fine-grained Authentication and Authorization (AuthN/Z) logic for your APIs beyond what standard identity providers offer out of the box. This is commonly used with API Gateways (like AWS API Gateway, Apigee, Kong) or frameworks (like Express.js, Spring Security).

Below, we’ll walk through two practical implementations:

  1. AWS API Gateway Lambda Custom Authorizer (OAuth 2.0 / JWT-based)
  2. Express.js Middleware Custom Authorizer (Node.js)

Both follow the AAA principles:

  • Authentication: Validate identity (e.g., JWT signature, issuer)
  • Authorization: Check scopes/roles/claims
  • Accounting: Log decisions for audit

πŸ” 1. AWS API Gateway Lambda Custom Authorizer (JWT Example)

Used when your API Gateway needs to validate tokens not handled by Cognito or IAM.

βœ… Use Case

API requires read:orders scope. Token is a JWT from Auth0/Okta/Keycloak.

Step 1: Create Lambda Authorizer Function (Node.js)

// lambda-authorizer.js
const jwkToPem = require('jwk-to-pem');
const jwt = require('jsonwebtoken');

// Fetch JWKs from your IdP (cache in production!)
const jwksUri = 'https://your-tenant.auth0.com/.well-known/jwks.json';

// In production, cache JWKs to avoid fetching on every call
let cachedJwk = null;

async function getSigningKey(kid) {
if (!cachedJwk) {
const res = await fetch(jwksUri);
const jwks = await res.json();
cachedJwk = jwks.keys.find(key => key.kid === kid);
if (!cachedJwk) throw new Error('Matching JWK not found');
}
return jwkToPem(cachedJwk);
}

exports.handler = async (event) => {
console.log('Auth event:', event);

const token = event.authorizationToken?.replace('Bearer ', '');
if (!token) {
return generatePolicy('unauthorized', 'Deny', event.methodArn);
}

try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error('Invalid token structure');
}

const pem = await getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, pem, {
issuer: 'https://your-tenant.auth0.com/',
audience: 'https://api.yourapp.com', // Your API identifier
});

// βœ… AUTHORIZATION: Check required scope (e.g., from event.methodArn)
const requiredScope = extractRequiredScope(event.methodArn);
const tokenScopes = payload.scope?.split(' ') || [];

if (!tokenScopes.includes(requiredScope)) {
console.warn(`Missing scope: ${requiredScope}`, { user: payload.sub });
return generatePolicy(payload.sub, 'Deny', event.methodArn);
}

// βœ… ACCOUNTING: Log successful auth (send to CloudWatch, SIEM, etc.)
console.log('Authorization granted', {
user: payload.sub,
client: payload.client_id,
scope: requiredScope,
methodArn: event.methodArn
});

return generatePolicy(payload.sub, 'Allow', event.methodArn);

} catch (err) {
console.error('Auth failed:', err.message);
return generatePolicy('invalid', 'Deny', event.methodArn);
}
};

// Helper: Map ARN to required scope (customize per your API)
function extractRequiredScope(methodArn) {
if (methodArn.includes('/orders') && methodArn.includes(':GET')) {
return 'read:orders';
}
if (methodArn.includes('/orders') && methodArn.includes(':POST')) {
return 'write:orders';
}
return 'read:default'; // fallback
}

// Helper: Generate IAM policy for API Gateway
function generatePolicy(principalId, effect, resource) {
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: resource
}]
},
context: {
// Optional: pass claims to backend via $context.authorizer.claims
scopes: (effect === 'Allow') ? 'granted' : 'denied'
}
};
};

Step 2: Deploy & Configure in AWS API Gateway

  1. Deploy Lambda (Node.js 18+, with jsonwebtoken and jwk-to-pem)
  2. In API Gateway β†’ Authorizers β†’ Create Lambda Authorizer
    • Lambda: your function
    • Token source: Authorization
    • Result TTL: 300 sec (cache successful results)
  3. Attach to routes:
    • In Method Request β†’ Authorization: your custom authorizer

βœ… Result: Only requests with valid JWT + correct scope pass. Backend receives event.requestContext.authorizer.principalId (user ID).


🌐 2. Express.js Custom Authorizer Middleware (Node.js)

For self-hosted APIs (e.g., on EC2, EKS, or serverless containers).

Step: Create Middleware

// authMiddleware.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
jwksUri: 'https://your-tenant.auth0.com/.well-known/jwks.json'
});

function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}

// Middleware factory: pass required scopes
function requireScopes(requiredScopes = []) {
return (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid token' });
}

const token = authHeader.split(' ')[1];

jwt.verify(token, getKey, {
issuer: 'https://your-tenant.auth0.com/',
audience: 'https://api.yourapp.com'
}, (err, payload) => {
if (err) {
console.warn('JWT verification failed:', err.message);
return res.status(401).json({ error: 'Unauthorized' });
}

// βœ… AUTHORIZATION: check scopes
const tokenScopes = (payload.scope || '').split(' ');
const hasAllScopes = requiredScopes.every(scope => tokenScopes.includes(scope));

if (!hasAllScopes) {
console.warn('Insufficient scopes', { user: payload.sub, required: requiredScopes });
return res.status(403).json({ error: 'Insufficient permissions' });
}

// βœ… ACCOUNTING: attach user context & log
req.user = {
sub: payload.sub,
scopes: tokenScopes,
client_id: payload.client_id
};

console.log('Access granted', {
user: payload.sub,
path: req.path,
method: req.method,
scopes: requiredScopes
});

next();
});
};
}

module.exports = requireScopes;

Step: Use in Routes

// app.js
const express = require('express');
const requireScopes = require('./authMiddleware');

const app = express();

app.get('/orders',
requireScopes(['read:orders']),
(req, res) => {
res.json({ message: 'Orders data', user: req.user.sub });
}
);

app.post('/orders',
requireScopes(['write:orders']),
(req, res) => {
res.status(201).json({ message: 'Order created' });
}
);

app.listen(3000);

βœ… Result: Clean, reusable middleware that enforces scopes per route. Logs provide audit trail.


πŸ”’ Best Practices for Custom Authorizers

AreaRecommendation
Token ValidationAlways verify signature, issuer (iss), audience (aud), and expiration (exp)
CachingCache JWKS/public keys (not tokens) to avoid latency
Least PrivilegeEnforce minimal required scopes per endpoint
LoggingLog denials + user context (avoid logging full tokens!)
Error HandlingNever leak internal errors (e.g., “JWT malformed” β†’ generic “Unauthorized”)
PerformanceKeep authorizer < 10 sec (AWS Lambda limit); use TTL caching

When to Use a Custom Authorizer?

βœ… Use when:

  • Your IdP doesn’t integrate natively with your gateway
  • You need custom logic (e.g., IP allowlist + token)
  • Fine-grained ABAC (e.g., β€œuser can only access their own orders”)

❌ Avoid if:

  • Standard OAuth/OIDC with scopes suffices
  • You can use built-in solutions (e.g., AWS Cognito, Apigee OAuth)
Scroll to Top