Identity and access
Every design has users signing in, and almost every team should not build that part. Identity providers handle passwords, passkeys, social logins, multi-factor checks and enterprise single sign-on, and hand your services short-lived tokens. This guide explains how the pieces fit, the products for customers and for employees, how they differ and which to choose, with a chooser and code you can run.
- Sam Should we build our own login?
- Priya Almost never. Identity providers handle passwords, passkeys, social logins, second factors and enterprise single sign-on, and hand your services short-lived tokens to check.
- Sam What are the pieces?
- Priya Authentication answers who this is, with OpenID Connect on top of OAuth two, SAML for older enterprise SSO, and passkeys for passwordless sign-in. Delegated access uses OAuth tokens with scopes. Authorization decides whether this user may do this to that object. And SCIM provisions employees from the company directory.
- Sam How is a request checked?
- Priya The app signs in with the authorization code flow and PKCE, and gets a short-lived access token and a refresh token. The gateway verifies the signature with the provider's published keys, then the issuer, audience and expiry, without calling the provider. The service that owns the data checks this user may see this object. And when the token expires, the app refreshes it.
- Sam What are the options?
- Priya Auth0, owned by Okta, for customer sign-in, and Okta for employees. Amazon Cognito on AWS. Firebase Authentication and Identity Platform on Google Cloud. Microsoft Entra ID for employees, and Entra External ID for customers; Azure AD B2C stopped being sold to new customers in May twenty twenty-five. Keycloak if you must run it yourself. And policy services like OPA, OpenFGA and Amazon Verified Permissions for authorization.
- Sam How do I choose?
- Priya Consumers on one cloud: that cloud's service. Richer flows or several clouds: Auth0. Employees: the company directory with SSO and SCIM. Business customers: per-tenant SAML or OIDC connections. Self-hosted: Keycloak.
- Priya Your turn. A valid token for the billing API arrives at the orders API. What should happen?
- Priya Reject it. The audience says which API the token is for.
- Sam And the code?
- Priya Five requests checked in order: signature, issuer, audience, expiry and scope, including a caller who edits the scope after signing.
- Sam Common mistakes?
- Priya Not checking the audience. Long-lived access tokens. Tokens in browser local storage. Trusting claims the client sends. And starting new work on a product that's being replaced.
1. Authentication, authorization and the standards
| Piece | Answers | Standard or tool |
|---|---|---|
| Authentication | Who is this? | OpenID Connect (OIDC) on top of OAuth 2.0; SAML for older enterprise SSO; passkeys (WebAuthn) for passwordless sign-in |
| Delegated access | May this app act for this user, and on what? | OAuth 2.0 access tokens with scopes; authorization code flow with PKCE for apps |
| Authorization | May this user do this to that object? | Your code, or a policy service: OPA, OpenFGA, Amazon Verified Permissions (Cedar) |
| Provisioning | Which employees exist, and in which groups? | SCIM, from the company's directory |
Module 26 walks through the OAuth flow and token handling; this guide is about the products.
2. How a request is checked
3. The products
Auth0 and Okta
Auth0 (owned by Okta) is a developer-focused identity platform for customer sign-in: hosted login pages, social and enterprise connections, multi-factor, passkeys, and rules or actions to customise flows. Okta's workforce products handle employee SSO and lifecycle.
Best for: customer sign-in on any cloud with rich features; employee SSO.
Amazon Cognito
User pools store users and issue OIDC tokens; identity pools exchange tokens for temporary AWS credentials. Integrates directly with API Gateway, ALB and AppSync.
Best for: customer sign-in for apps built on AWS.
Firebase Authentication and Identity Platform
Client SDKs for email, phone, social and anonymous sign-in, with Identity Platform adding enterprise features (SAML and OIDC providers, multi-tenancy, blocking functions) on Google Cloud.
Best for: mobile and web apps on Firebase or Google Cloud.
Microsoft Entra ID and External ID
Entra ID (formerly Azure Active Directory) is the employee directory and SSO for Microsoft 365 and thousands of apps. For customer sign-in, Microsoft Entra External ID is the current product; Azure AD B2C stopped being sold to new customers on 1 May 2025 and is supported until at least May 2030.
Best for: employee SSO in Microsoft shops; External ID for customers on Azure.
Keycloak
A full identity server you run yourself: OIDC, OAuth 2.0 and SAML, user federation with LDAP and Active Directory, social login, fine-grained admin. Other open-source options include Ory, Authentik and ZITADEL.
Best for: full control, on-premises or regulated environments.
OPA, OpenFGA, Verified Permissions
Open Policy Agent evaluates policies written in Rego; OpenFGA (inspired by Google's Zanzibar) answers "can user X do Y on object Z" from relationship tuples; Amazon Verified Permissions evaluates Cedar policies as a managed service.
Best for: permissions that outgrow if-statements: sharing, roles per object, audits.
4. Differences at a glance
| Option | For | Hosted? | Standards | Notable |
|---|---|---|---|---|
| Auth0 | Customers | Yes | OIDC, OAuth, SAML | Customisable flows, many connections |
| Okta Workforce | Employees | Yes | SAML, OIDC, SCIM | Large app catalogue |
| Cognito | Customers | Yes (AWS) | OIDC, OAuth, SAML federation | AWS credentials via identity pools |
| Firebase Auth / Identity Platform | Customers | Yes (Google) | OIDC, SAML (Identity Platform) | Client SDKs, multi-tenancy |
| Entra ID | Employees | Yes (Microsoft) | SAML, OIDC, SCIM | Microsoft 365 directory |
| Entra External ID | Customers | Yes (Microsoft) | OIDC, OAuth, SAML | Replaces Azure AD B2C for new customers |
| Keycloak | Both | No, self-run | OIDC, OAuth, SAML | LDAP and AD federation |
5. When to choose which, and why
- Don't build password storage, MFA and recovery yourself. Use a provider and OIDC; your services only verify tokens.
- Customers on one cloud? That cloud's service (Cognito, Identity Platform, External ID) is the cheapest and most integrated; Auth0 when you need richer flows or are multi-cloud.
- Employees? The company directory (Entra ID, Okta, Google Workspace) with SSO and SCIM provisioning into your app.
- Business customers want "sign in with our company account"? Choose a provider that supports SAML and OIDC enterprise connections per tenant.
- Must run on your own servers? Keycloak or another open-source identity server.
Try it: choose for your requirements
6. The checks an API makes on a token, in code
Five requests reach an orders API with bearer tokens. The API checks, in order: the signature, the issuer, the audience (is this token meant for this API?), expiry with a minute of clock skew, and the scope the action needs. A caller who edits the scope after signing fails the signature check. The tokens are real JWTs signed with HS256 (HMAC-SHA256), because a shared secret works with each language's standard library; identity providers usually sign with RS256 or ES256 instead, and your API verifies with their published public keys (JWKS), through a JWT library. The program was run in all three languages, which produce the same signature; each prints the output below it.
import base64, hashlib, hmac, json
ISSUER, AUDIENCE, NOW, SKEW = "https://login.example.com", "orders-api", 1_000_000, 60
SECRET = b"demo-secret-not-for-production" # HS256 shares one secret; providers usually sign with RS256 or ES256 keys from a JWKS URL
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def unb64url(text):
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def sign(header_b64, payload_b64):
return b64url(hmac.new(SECRET, f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest())
def issue(**overrides):
claims = {"iss": ISSUER, "aud": AUDIENCE, "sub": "user-42", "exp": NOW + 900, "scope": "orders:read"}
claims.update(overrides)
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps(claims, separators=(",", ":")).encode())
return f"{header}.{payload}.{sign(header, payload)}"
def check(token, needed):
"""The checks every API must make, in this order, before trusting a bearer token."""
header_b64, payload_b64, signature = token.split(".")
if json.loads(unb64url(header_b64))["alg"] != "HS256":
return "401 unexpected algorithm" # never accept "none" or an algorithm you didn't choose
if not hmac.compare_digest(sign(header_b64, payload_b64), signature):
return "401 bad signature (token was changed or not issued by us)"
claims = json.loads(unb64url(payload_b64))
if claims["iss"] != ISSUER:
return "401 wrong issuer"
if claims["aud"] != AUDIENCE:
return f"401 token is for {claims['aud']}, not {AUDIENCE}"
if claims["exp"] + SKEW < NOW:
return f"401 expired {NOW - claims['exp']} s ago"
if needed not in claims["scope"].split():
return f"403 missing scope {needed}"
return f"200 ok, acting as {claims['sub']}"
valid = issue()
print("token:", valid[:60] + "...")
print("signature:", valid.split(".")[2])
header_b64, payload_b64, sig = valid.split(".")
edited = json.loads(unb64url(payload_b64))
edited["scope"] = "orders:read orders:write" # the caller edits the claims and keeps the old signature
tampered = f"{header_b64}.{b64url(json.dumps(edited, separators=(',', ':')).encode())}.{sig}"
cases = [
("valid token, reading orders", valid, "orders:read"),
("expired an hour ago", issue(exp=NOW - 3600), "orders:read"),
("token meant for another API", issue(aud="billing-api"), "orders:read"),
("valid token, but writing", valid, "orders:write"),
("scope edited by the caller", tampered, "orders:write"),
]
for name, token, needed in cases:
print(f"{name:30} -> {check(token, needed)}")
import { createHmac, timingSafeEqual } from "node:crypto";
const ISSUER = "https://login.example.com", AUDIENCE = "orders-api", NOW = 1_000_000, SKEW = 60;
const SECRET = "demo-secret-not-for-production"; // HS256 shares one secret; providers usually sign with RS256 or ES256 keys from a JWKS URL
type Claims = { iss: string; aud: string; sub: string; exp: number; scope: string };
const b64url = (data: string | Buffer) => Buffer.from(data).toString("base64url");
const unb64url = (text: string) => Buffer.from(text, "base64url").toString();
const sign = (headerB64: string, payloadB64: string) => createHmac("sha256", SECRET).update(`${headerB64}.${payloadB64}`).digest("base64url");
function issue(overrides: Partial<Claims> = {}): string {
const claims: Claims = { iss: ISSUER, aud: AUDIENCE, sub: "user-42", exp: NOW + 900, scope: "orders:read", ...overrides };
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = b64url(JSON.stringify(claims));
return `${header}.${payload}.${sign(header, payload)}`;
}
// The checks every API must make, in this order, before trusting a bearer token.
function check(token: string, needed: string): string {
const [headerB64, payloadB64, signature] = token.split(".");
if (JSON.parse(unb64url(headerB64)).alg !== "HS256") return "401 unexpected algorithm"; // never accept "none" or an algorithm you didn't choose
const expected = Buffer.from(sign(headerB64, payloadB64)), given = Buffer.from(signature);
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return "401 bad signature (token was changed or not issued by us)";
const c: Claims = JSON.parse(unb64url(payloadB64));
if (c.iss !== ISSUER) return "401 wrong issuer";
if (c.aud !== AUDIENCE) return `401 token is for ${c.aud}, not ${AUDIENCE}`;
if (c.exp + SKEW < NOW) return `401 expired ${NOW - c.exp} s ago`;
if (!c.scope.split(" ").includes(needed)) return `403 missing scope ${needed}`;
return `200 ok, acting as ${c.sub}`;
}
const valid = issue();
console.log("token:", valid.slice(0, 60) + "...");
console.log("signature:", valid.split(".")[2]);
const [headerB64, payloadB64, sig] = valid.split(".");
const edited = { ...JSON.parse(unb64url(payloadB64)), scope: "orders:read orders:write" }; // the caller edits the claims and keeps the old signature
const tampered = `${headerB64}.${b64url(JSON.stringify(edited))}.${sig}`;
const cases: [string, string, string][] = [
["valid token, reading orders", valid, "orders:read"],
["expired an hour ago", issue({ exp: NOW - 3600 }), "orders:read"],
["token meant for another API", issue({ aud: "billing-api" }), "orders:read"],
["valid token, but writing", valid, "orders:write"],
["scope edited by the caller", tampered, "orders:write"],
];
for (const [name, token, needed] of cases) console.log(`${name.padEnd(30)} -> ${check(token, needed)}`);import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.regex.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Main {
static final String ISSUER = "https://login.example.com", AUDIENCE = "orders-api";
static final long NOW = 1_000_000, SKEW = 60;
static final byte[] SECRET = "demo-secret-not-for-production".getBytes(StandardCharsets.UTF_8); // HS256 shares one secret; providers usually sign with RS256 or ES256 keys from a JWKS URL
static String b64url(byte[] data) { return Base64.getUrlEncoder().withoutPadding().encodeToString(data); }
static String b64url(String text) { return b64url(text.getBytes(StandardCharsets.UTF_8)); }
static String unb64url(String text) { return new String(Base64.getUrlDecoder().decode(text), StandardCharsets.UTF_8); }
static String sign(String headerB64, String payloadB64) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
return b64url(mac.doFinal((headerB64 + "." + payloadB64).getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) { throw new IllegalStateException(e); }
}
// Claims are written as compact JSON in a fixed order, matching the Python and TypeScript output byte for byte.
static String json(Map<String, Object> m) {
StringJoiner j = new StringJoiner(",", "{", "}");
m.forEach((k, v) -> j.add("\"" + k + "\":" + (v instanceof String ? "\"" + v + "\"" : v)));
return j.toString();
}
// A tiny reader for flat JSON objects of strings and integers; real code uses a JSON library and a JWT library.
static Map<String, String> parse(String text) {
Map<String, String> m = new LinkedHashMap<>();
Matcher x = Pattern.compile("\"(\\w+)\":(?:\"([^\"]*)\"|(-?\\d+))").matcher(text);
while (x.find()) m.put(x.group(1), x.group(2) != null ? x.group(2) : x.group(3));
return m;
}
static Map<String, Object> claims() {
Map<String, Object> c = new LinkedHashMap<>();
c.put("iss", ISSUER); c.put("aud", AUDIENCE); c.put("sub", "user-42"); c.put("exp", NOW + 900); c.put("scope", "orders:read");
return c;
}
static String issue(Map<String, Object> c) {
Map<String, Object> h = new LinkedHashMap<>();
h.put("alg", "HS256"); h.put("typ", "JWT");
String header = b64url(json(h)), payload = b64url(json(c));
return header + "." + payload + "." + sign(header, payload);
}
// The checks every API must make, in this order, before trusting a bearer token.
static String check(String token, String needed) {
String[] p = token.split("\\.");
if (!"HS256".equals(parse(unb64url(p[0])).get("alg"))) return "401 unexpected algorithm"; // never accept "none" or an algorithm you didn't choose
if (!MessageDigest.isEqual(sign(p[0], p[1]).getBytes(StandardCharsets.UTF_8), p[2].getBytes(StandardCharsets.UTF_8)))
return "401 bad signature (token was changed or not issued by us)";
Map<String, String> c = parse(unb64url(p[1]));
if (!c.get("iss").equals(ISSUER)) return "401 wrong issuer";
if (!c.get("aud").equals(AUDIENCE)) return "401 token is for " + c.get("aud") + ", not " + AUDIENCE;
long exp = Long.parseLong(c.get("exp"));
if (exp + SKEW < NOW) return "401 expired " + (NOW - exp) + " s ago";
if (!Arrays.asList(c.get("scope").split(" ")).contains(needed)) return "403 missing scope " + needed;
return "200 ok, acting as " + c.get("sub");
}
public static void main(String[] args) {
String valid = issue(claims());
System.out.println("token: " + valid.substring(0, 60) + "...");
System.out.println("signature: " + valid.split("\\.")[2]);
String[] p = valid.split("\\.");
Map<String, Object> edited = claims();
edited.put("scope", "orders:read orders:write"); // the caller edits the claims and keeps the old signature
String tampered = p[0] + "." + b64url(json(edited)) + "." + p[2];
Map<String, Object> expired = claims(); expired.put("exp", NOW - 3600);
Map<String, Object> other = claims(); other.put("aud", "billing-api");
String[][] cases = {
{"valid token, reading orders", valid, "orders:read"},
{"expired an hour ago", issue(expired), "orders:read"},
{"token meant for another API", issue(other), "orders:read"},
{"valid token, but writing", valid, "orders:write"},
{"scope edited by the caller", tampered, "orders:write"},
};
for (String[] k : cases) System.out.printf("%-30s -> %s%n", k[0], check(k[1], k[2]));
}
}token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2x...
signature: xqi1yxQ57PVKLfUZIf3lkJydziqBlAiABNOm25rqF68
valid token, reading orders -> 200 ok, acting as user-42
expired an hour ago -> 401 expired 3600 s ago
token meant for another API -> 401 token is for billing-api, not orders-api
valid token, but writing -> 403 missing scope orders:write
scope edited by the caller -> 401 bad signature (token was changed or not issued by us)
7. Say it in the interview
- "How do users sign in?" Through a managed identity provider with OIDC, using the authorization code flow with PKCE; the app gets a short-lived access token and a refresh token. The gateway verifies the token's signature, issuer, audience and expiry on every request.
- "Where are permissions checked?" Coarse checks (scopes, roles) at the gateway; checks on specific objects ("can this user edit this document?") in the service that owns the data, or in a policy service such as OpenFGA when sharing rules get complex.
- "Enterprise customers want SSO." Per-tenant SAML or OIDC connections in the identity provider, and SCIM so their directory provisions and removes users automatically.
8. Common mistakes
aud on every request.9. Check yourself
A token issued for billing-api is sent to orders-api. Signature, issuer and expiry are fine. What should orders-api do?
A caller edits the scope inside a signed token to add orders:write. What catches it?
A new consumer app on Azure needs customer sign-in. Which product?
Users share documents with each other and with groups. Where do those permission checks fit best?
10. Sources and further reading
Product facts checked on 4 November 2026. Products and plans change; check before relying on them.
| Source | Used for |
|---|---|
| Microsoft Entra External ID FAQ | Azure AD B2C availability and support dates |
| How OpenID Connect works | OIDC on top of OAuth 2.0 |
| RFC 7636: PKCE | Code exchange for public clients |
| OpenFGA | Relationship-based authorization |
| Module 26: Security | OAuth flow, tokens and authorization models |