Authentication and Authorization Patterns
These concepts are foundational to every backend service, and getting them right is more critical than ever as applications grow in complexity and regulatory requirements tighten

As engineers, we often reach for familiar libraries and frameworks when building features, but authentication and authorization tend to be the areas where “familiar” can quietly turn into “fragile.” If you have ever traced a production bug to a missing role check or an expired token that should have been refreshed, you know how subtle missteps in this domain can cascade into security issues and user frustration. There are many patterns in this space, from basic session cookies to fine-grained policy engines, and each comes with tradeoffs that depend on your application’s shape, your team’s size, and your operational reality.
In this post, I will walk through the patterns I have used and seen succeed in real-world systems. We will cover the conceptual differences between authentication and authorization, examine common architectures, and explore practical implementations in Node.js and Go. Along the way, I will point out where each pattern shines, where it can bite you, and how to choose an approach that fits your project’s growth path. If you are building an API, a multi-tenant SaaS, or a dashboard with complex roles, this guide will help you build security in from the start rather than bolting it on later.
Where these patterns fit today
Authentication and authorization are not just “user login.” In modern backends, they show up as OAuth 2.0 flows for third-party access, JSON Web Tokens (JWT) for stateless APIs, session cookies for server-rendered apps, and policy engines like Open Policy Agent for complex authorization rules. In microservice architectures, we often deal with service-to-service authentication alongside user authentication, and the boundary between the two can be easy to blur. Meanwhile, regulations like GDPR and industry standards like PCI DSS push us toward stronger guarantees around identity and access control.
Who uses these patterns? Every backend developer building APIs or user-facing applications. The difference between a small team and a large one often lies in how formally the authorization model is defined. A startup might rely on simple role checks in the code, while a larger organization might centralize policies in a dedicated engine. Compared to alternatives like ad hoc permissions scattered across the codebase, structured patterns reduce risk and make audits easier. Compared to relying entirely on external identity providers, a self-managed approach can offer more control and customization but also increases operational overhead.
Core concepts: authentication vs. authorization
It helps to separate the concerns clearly. Authentication answers “who are you?” while authorization answers “what are you allowed to do?” A typical flow looks like this: a user authenticates, the system issues a credential (session cookie or token), and subsequent requests carry that credential. Authorization checks then evaluate the subject’s identity and attributes against policies or roles.
In practice, authentication is often delegated to a trusted identity provider (IdP), especially in enterprise contexts. Authorization remains in the application layer because business rules are specific to the domain. In distributed systems, we sometimes pass identity context between services, often using a token format like JWT that can be verified without centralizing session state.
A few grounding points:
- Keep secrets off logs and client code.
- Prefer short-lived credentials with refresh mechanisms for tokens.
- Distinguish between “authentication” (identity proof) and “authorization” (access decision) in your code and your APIs.
Common patterns and tradeoffs
Session cookies and CSRF protection
For server-rendered applications, session cookies remain a reliable choice. The server sets a secure, HttpOnly cookie containing a session ID, and session data lives server-side (in a database or cache like Redis). This approach works well with traditional web apps and simplifies logout and token revocation.
Tradeoffs: cookies require CSRF protection. Modern frameworks provide middleware to enforce SameSite attributes and CSRF tokens. Also, session storage introduces state, which can complicate horizontal scaling unless you use a shared store.
JWTs for stateless APIs
For APIs consumed by SPAs or mobile apps, JWTs are popular. A signed token carries claims and an expiration. The API verifies the signature without contacting an authority on each request. JWTs enable stateless scaling and can carry minimal identity context (e.g., user ID, roles).
Tradeoffs: JWTs are hard to revoke before expiry unless you maintain a blocklist, which reintroduces state. Long token lifetimes increase risk. Keep tokens short and use refresh tokens to obtain new access tokens. Always validate the signature and standard claims like “iss,” “aud,” and “exp.”
OAuth 2.0 and OpenID Connect
OAuth 2.0 is about delegated authorization; OpenID Connect (OIDC) builds identity on top. For third-party app access or single sign-on, using an IdP like Auth0, Okta, or Keycloak avoids reinventing the wheel. Your app becomes an OAuth client or resource server.
Tradeoffs: the flows can be complex. Redirects and token handling must be done carefully to avoid leaks. For internal services, the overhead may be unnecessary.
Service-to-service authentication
In microservices, mTLS is a robust pattern for service identity. Each service holds a client certificate, and servers verify certificates on incoming connections. Another pattern is issuing short-lived JWTs to services via a central authority. API gateways can terminate TLS and forward identity headers to upstream services.
Tradeoffs: mTLS adds operational complexity (certificate issuance, rotation). JWTs for services are simpler but require strict audience scoping and clock synchronization.
Fine-grained authorization with policy engines
As authorization rules grow, role-based access control (RBAC) can become coarse. Attribute-based access control (ABAC) or policy engines like Open Policy Agent (OPA) allow you to express rules in a declarative language (Rego). Policies can be versioned, tested, and evaluated at the edge or within services.
Tradeoffs: policy engines add a learning curve and evaluation overhead. They are most valuable when you have complex, evolving rules or multiple services needing consistent authorization.
Practical implementation: Node.js with JWT and refresh tokens
Below is a compact Node.js example illustrating authentication with JWTs and refresh tokens, plus a simple role-based authorization middleware. This is a realistic starting point for an API, not a full production system. It uses Express, bcrypt for password hashing, and jsonwebtoken for signing.
Project structure overview:
src/index.js: app entrysrc/auth.js: authentication middleware and token utilitiessrc/routes.js: API routessrc/db.js: simple in-memory user store (replace with real DB)
// src/index.js
const express = require('express');
const cookieParser = require('cookie-parser');
const { login, register, refreshToken, requireAuth, requireRole } = require('./auth');
const { findUserByUsername } = require('./db');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Registration and login
app.post('/register', register);
app.post('/login', login);
app.post('/refresh', refreshToken);
// Protected routes
app.get('/profile', requireAuth, (req, res) => {
// req.user is set by requireAuth from the access token
const user = findUserByUsername(req.user.username);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ username: user.username, roles: user.roles });
});
app.delete('/admin/users/:id', requireAuth, requireRole('admin'), (req, res) => {
// Authorization check enforced by requireRole
res.json({ ok: true, message: 'User deleted' });
});
app.listen(3000, () => console.log('API listening on :3000'));
// src/auth.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { createUser, findUserByUsername } = require('./db');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const ACCESS_TOKEN_AGE = 15 * 60; // 15 minutes
const REFRESH_TOKEN_AGE = 7 * 24 * 60 * 60; // 7 days
// Generate tokens
function generateAccessToken(user) {
return jwt.sign(
{ sub: user.id, username: user.username, roles: user.roles },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_AGE, issuer: 'myapp', audience: 'api' }
);
}
function generateRefreshToken(user) {
return jwt.sign(
{ sub: user.id, kind: 'refresh' },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_AGE, issuer: 'myapp', audience: 'api' }
);
}
// Registration handler
async function register(req, res) {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Missing username or password' });
}
if (findUserByUsername(username)) {
return res.status(409).json({ error: 'Username already exists' });
}
const hashed = await bcrypt.hash(password, 10);
const user = createUser(username, hashed, ['user']);
res.status(201).json({ ok: true, userId: user.id });
}
// Login handler
async function login(req, res) {
const { username, password } = req.body;
const user = findUserByUsername(username);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Set refresh token as HttpOnly cookie; access token returned in body for API usage
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: REFRESH_TOKEN_AGE * 1000
});
res.json({ access_token: accessToken, expires_in: ACCESS_TOKEN_AGE });
}
// Refresh token handler
function refreshToken(req, res) {
const token = req.cookies.refresh_token;
if (!token) return res.status(401).json({ error: 'No refresh token' });
try {
const payload = jwt.verify(token, JWT_SECRET, {
issuer: 'myapp',
audience: 'api'
});
if (payload.kind !== 'refresh') {
return res.status(401).json({ error: 'Invalid token kind' });
}
const user = { id: payload.sub, username: 'unknown', roles: ['user'] }; // Replace with DB lookup
const newAccess = generateAccessToken(user);
res.json({ access_token: newAccess, expires_in: ACCESS_TOKEN_AGE });
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
}
// Authentication middleware
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET, {
issuer: 'myapp',
audience: 'api'
});
req.user = { id: payload.sub, username: payload.username, roles: payload.roles || [] };
next();
} catch (err) {
res.status(401).json({ error: 'Invalid access token' });
}
}
// Role-based authorization middleware
function requireRole(role) {
return (req, res, next) => {
if (!req.user || !req.user.roles || !req.user.roles.includes(role)) {
return res.status(403).json({ error: 'Forbidden: insufficient role' });
}
next();
};
}
module.exports = {
register,
login,
refreshToken,
requireAuth,
requireRole
};
// src/db.js (in-memory for demo)
const users = new Map();
let nextId = 1;
function createUser(username, passwordHash, roles) {
const id = nextId++;
const user = { id, username, passwordHash, roles };
users.set(username, user);
return user;
}
function findUserByUsername(username) {
return users.get(username);
}
module.exports = { createUser, findUserByUsername };
Notes on this approach:
- The refresh token is stored in an HttpOnly cookie to reduce XSS risk; the access token is returned in the response body. In a browser, you might store the access token in memory rather than localStorage.
- The access token lifetime is short; refresh logic should handle rotation and revocation in production. Consider a server-side refresh token store to enable revocation on logout.
- Authorization is simple RBAC here. For complex rules, consider moving to a policy engine.
Practical implementation: Go with middleware and mTLS for services
In Go, you can implement middleware that validates JWTs and checks roles. For service-to-service authentication, mTLS is a common choice. The example below shows a simple HTTP server with JWT middleware and a brief sketch of mTLS configuration.
// main.go
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
type Claims struct {
Sub string `json:"sub"`
Roles []string `json:"roles"`
Scopes []string `json:"scopes"`
jwt.RegisteredClaims
}
// authMiddleware validates JWT and attaches claims to request context
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
tokenStr := auth[7:]
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Optional: verify audience and issuer
if claims.Issuer != "myapp" {
http.Error(w, "invalid issuer", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireRole checks if the user has the required role
func requireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value("user").(*Claims)
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
for _, r := range user.Roles {
if r == role {
next.ServeHTTP(w, r)
return
}
}
http.Error(w, "forbidden", http.StatusForbidden)
})
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
// In a real app, validate username/password and issue a token
claims := Claims{
Sub: "123",
Roles: []string{"admin"},
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
Issuer: "myapp",
Audience: []string{"api"},
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, _ := token.SignedString(jwtSecret)
w.Write([]byte(tokenStr))
})
// Protected route with role check
mux.Handle("/admin", authMiddleware(requireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Welcome admin"))
}))))
// Basic server
log.Println("Starting server on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
For mTLS between services, the server configuration changes. Here’s a minimal server that requires a client certificate:
// mtls_server.go
package main
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"log"
"net/http"
)
func mtlsHandler(w http.ResponseWriter, r *http.Request) {
peerCerts := r.TLS.PeerCertificates
if len(peerCerts) == 0 {
http.Error(w, "no client certificate", http.StatusUnauthorized)
return
}
// Extract identity from client cert subject or SAN
w.Write([]byte("Authenticated service: " + peerCerts[0].Subject.CommonName))
}
func main() {
caCert, err := ioutil.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caCertPool,
}
server := &http.Server{
Addr: ":8443",
Handler: http.HandlerFunc(mtlsHandler),
TLSConfig: tlsConfig,
}
log.Println("Starting mTLS server on :8443")
log.Fatal(server.ListenAndServeTLS("server.crt", "server.key"))
}
A quick note on usage: use a tool like mkcert for local development to generate trusted certificates. In production, use a proper CA and certificate lifecycle management.
Evaluating strengths and weaknesses
Here is an honest assessment to guide your choices:
Session cookies
- Strengths: simple for web apps, built-in CSRF protections, easy logout and revocation.
- Weaknesses: stateful; requires shared storage for horizontal scaling; less suited for SPAs or mobile APIs.
JWTs
- Strengths: stateless, scalable, works well for APIs and microservices.
- Weaknesses: revocation is hard without a blocklist; token theft risk if stored unsafely in the browser; requires careful validation.
OAuth 2.0 / OIDC
- Strengths: industry standard, robust for federated identity, good security posture with consent flows.
- Weaknesses: complexity in implementation and debugging; dependency on external IdP.
mTLS
- Strengths: strong service identity, no secrets to leak, works at transport layer.
- Weaknesses: operational overhead (cert issuance and rotation); debugging is harder; can be overkill for simple apps.
Policy engines (OPA)
- Strengths: centralized, testable authorization logic; expressive rules; consistent across services.
- Weaknesses: added dependency and learning curve; policy evaluation latency; requires governance.
Rule of thumb: start simple. Use session cookies for traditional web apps, JWTs for APIs, and consider an IdP if you need SSO or third-party access. As complexity grows, introduce a policy engine and formalize your RBAC/ABAC model.
Personal experience: learning curves and common mistakes
In several projects, the most common mistakes were:
- Putting sensitive data in JWTs or logs. Keep payloads minimal; never put secrets in tokens.
- Long token lifetimes for convenience. This leads to prolonged exposure if tokens leak. Keep access tokens short and rotate refresh tokens.
- Forgetting CSRF protection for cookie-based auth. Even with SameSite set, some browsers and flows still require CSRF tokens.
- Scattergun role checks. Without a clear role taxonomy, developers invent roles inconsistently. Define roles once and use centralized middleware.
A moment where these patterns proved especially valuable: implementing refresh token rotation with revocation on suspicious activity. In a multi-tenant API, we caught a compromised refresh token and rotated it out without impacting legitimate users. The ability to invalidate sessions and re-authenticate safely prevented a larger incident.
Getting started: workflow and mental models
When starting a new project:
- Define your authentication model: who are your users, and how do they authenticate (password, SSO, social)?
- Choose your credential format: session cookies for web-first apps, JWTs for API-first apps.
- Decide on authorization granularity: simple roles, or attributes and policies?
- Consider your trust boundaries: user-facing endpoints, internal services, third-party integrations.
A minimal folder structure for a Node.js API with auth:
src/
auth.js # auth middleware, token utilities
routes.js # API endpoints
db.js # data access
server.js # app entry
test/
auth.test.js # unit tests for auth logic
.env # environment variables (JWT_SECRET, DB_URL)
Tooling checklist:
- Secrets management: environment variables in dev, secret store in prod (e.g., AWS Secrets Manager).
- Logging: avoid logging tokens; include request IDs for tracing.
- Testing: mock token verification; test role-based access paths; include negative tests for expired or tampered tokens.
- Observability: metrics for auth failures, token refresh rates, and authorization denials.
For Go services, adopt a similar structure and consider using a standard library like golang-jwt and crypto/tls for mTLS. Use go test to validate middleware behavior and token parsing.
Free learning resources
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html — practical guidance for secure authentication.
- OAuth 2.0 Simplified: https://aaronparecki.com/oauth-2-simplified/ — an approachable overview of OAuth 2.0 flows.
- Open Policy Agent documentation: https://www.openpolicyagent.org/docs/latest/ — learn how to write and test policies for authorization.
- JWT.io: https://jwt.io/ — decode and debug JWTs; useful for understanding token contents and signatures.
- Auth0 Blog and Resources: https://auth0.com/blog — in-depth articles on modern identity patterns and best practices.
- NIST SP 800-63 (Digital Identity Guidelines): https://csrc.nist.gov/publications/detail/sp/800-63/3/final — authoritative reference for identity assurance levels.
Who should use these patterns and who might skip
If you are building any backend that exposes APIs or handles user data, you should adopt structured authentication and authorization patterns. Start with clear separation of authentication and authorization, choose a credential format that fits your app, and layer in stronger controls as your threat model evolves.
If you are building a simple internal tool with low risk, you might rely on basic HTTP basic auth with strict network controls. But even then, consider the future. Systems grow, and retrofitting security is painful. The patterns in this post scale with your application, from a single service to a distributed system.
Takeaway: prioritize short-lived credentials, explicit role definitions, and centralized authorization logic. These choices reduce risk, improve maintainability, and make audits straightforward. As you grow, layer in an IdP for SSO and a policy engine for complex rules. The goal is not perfect security but a robust, understandable system that you can reason about and improve over time.




