InterviewKata
System design · Technology guide T12 · Builds on Module 26

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.

A 3-minute conversation: Sam asks, Priya explains
0:00 / 2:33

1. Authentication, authorization and the standards

PieceAnswersStandard or tool
AuthenticationWho is this?OpenID Connect (OIDC) on top of OAuth 2.0; SAML for older enterprise SSO; passkeys (WebAuthn) for passwordless sign-in
Delegated accessMay this app act for this user, and on what?OAuth 2.0 access tokens with scopes; authorization code flow with PKCE for apps
AuthorizationMay this user do this to that object?Your code, or a policy service: OPA, OpenFGA, Amazon Verified Permissions (Cedar)
ProvisioningWhich 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

Customer and workforce identity · Okta

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.

Customer identity · AWS

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.

Customer identity · Google Cloud

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.

Workforce and customer identity · Microsoft

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.

Self-hosted · open source (CNCF)

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.

Authorization services

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

OptionForHosted?StandardsNotable
Auth0CustomersYesOIDC, OAuth, SAMLCustomisable flows, many connections
Okta WorkforceEmployeesYesSAML, OIDC, SCIMLarge app catalogue
CognitoCustomersYes (AWS)OIDC, OAuth, SAML federationAWS credentials via identity pools
Firebase Auth / Identity PlatformCustomersYes (Google)OIDC, SAML (Identity Platform)Client SDKs, multi-tenancy
Entra IDEmployeesYes (Microsoft)SAML, OIDC, SCIMMicrosoft 365 directory
Entra External IDCustomersYes (Microsoft)OIDC, OAuth, SAMLReplaces Azure AD B2C for new customers
KeycloakBothNo, self-runOIDC, OAuth, SAMLLDAP and AD federation

5. When to choose which, and why

  1. Don't build password storage, MFA and recovery yourself. Use a provider and OIDC; your services only verify tokens.
  2. 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.
  3. Employees? The company directory (Entra ID, Okta, Google Workspace) with SSO and SCIM provisioning into your app.
  4. Business customers want "sign in with our company account"? Choose a provider that supports SAML and OIDC enterprise connections per tenant.
  5. 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

Two-sentence answers
  • "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

Not checking the audienceA valid token for another API is still valid-looking. Check aud on every request.
Long-lived access tokensJWTs can't be recalled. Keep them to minutes and use refresh tokens that can be revoked.
Tokens in browser local storageAny injected script can read them. Prefer secure, HTTP-only cookies or a backend-for-frontend.
Trusting the client's claimsRoles or tenant IDs sent by the client must come from the verified token or your database, never from the request body.
Starting on a product that's being replacedNew Azure customer-identity work belongs on Entra External ID, not Azure AD B2C.

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?

The aud claim says which API a token is for.

A caller edits the scope inside a signed token to add orders:write. What catches it?

Changing any signed claim invalidates the signature.

A new consumer app on Azure needs customer sign-in. Which product?

Azure AD B2C hasn't been sold to new customers since May 2025.

Users share documents with each other and with groups. Where do those permission checks fit best?

Per-object sharing changes constantly; tokens carry who you are, not every object you may touch.

10. Sources and further reading

Product facts checked on 4 November 2026. Products and plans change; check before relying on them.

SourceUsed for
Microsoft Entra External ID FAQAzure AD B2C availability and support dates
How OpenID Connect worksOIDC on top of OAuth 2.0
RFC 7636: PKCECode exchange for public clients
OpenFGARelationship-based authorization
Module 26: SecurityOAuth flow, tokens and authorization models