Flow
Sign-in and authorization

Building a web app

Register an OAuth2 client, sign a person in with the authorization code flow and PKCE, call services with the access token, and sign out.

A web app never sees a password. It sends the person to Hydra, the sign-in app handles the rest, and the app gets back an access token for the person. This page walks through that using the sample client flow-sample-web.

The sample web app, frontend/sample-app, does every step on this page and is the reference to copy. It runs locally only, on http://127.0.0.1:8765. Its README explains how to run it, how its cookies work, and what a real app should do differently.

Read Sign-in and authorization first for how the pieces fit together.

Addresses

LocalDev
Hydra (issuer, /oauth2/...)http://127.0.0.1:4444https://authn.dev.apps-and-services.com
Signing keys (JWKS)http://127.0.0.1:4444/.well-known/jwks.jsonhttps://authn.dev.apps-and-services.com/.well-known/jwks.json

Locally, use 127.0.0.1 rather than localhost everywhere. The issuer is compared as an exact string, and cookies set for one name are not sent to the other. How to start the local stack is in services/authn/README.md.

Register an OAuth2 client

Every OAuth2 client: An app or service registered with Hydra so it can get access tokens. Web and mobile apps are public clients that use authorization code with PKCE; services use client_credentials. Each has exactly one audience. that signs a person in is defined in the repository, so every change to one is reviewed. There is one file per environment: services/authn/hydra/clients/dev.json and services/authn/hydra/clients/local.json. The deploy creates or updates each client in Hydra. This is the sample web app's entry:

{
  "audience": ["https://api.flow.internal"],
  "client_id": "flow-sample-web",
  "client_name": "Flow sample web app",
  "grant_types": ["authorization_code"],
  "metadata": { "first_party": true },
  "post_logout_redirect_uris": ["http://127.0.0.1:8765/logged-out"],
  "redirect_uris": ["http://127.0.0.1:8765/callback"],
  "response_types": ["code"],
  "scope": "openid",
  "token_endpoint_auth_method": "none"
}

A browser app is a public client: it cannot keep a secret, so token_endpoint_auth_method is none and PKCE protects the code instead. The main rules, which go test ./services/authn/... enforces:

  • client_id is lowercase letters, digits and -, and is never shaped like a UUID.
  • Exactly one audience: the platform audience, https://api.flow.internal. Every service an app calls accepts it, identity included, so one token works for all of them. See docs/decisions/0055-platform-api-audience.md.
  • Redirect URIs are exact, with no wildcards. Plain http is allowed only as http://127.0.0.1:<port>/..., and only in dev.json and local.json.
  • No refresh tokens: no refresh_token grant and no offline_access scope. A refresh token would outlive a sign-out.
  • metadata.first_party is true. The sign-in app refuses consent for any other client.

The full list, and how to write the file in its required format, is under OAuth2 clients in services/authn/README.md. The file must be sorted and indented the way jq -S . writes it.

Send the person to Hydra

Generate a random state of at least 16 characters and a PKCE code_verifier, and keep both for the callback (in a cookie or the session). Then redirect the browser to Hydra's authorization endpoint:

GET {issuer}/oauth2/auth
  ?client_id=flow-sample-web
  &response_type=code
  &scope=openid
  &audience=https://api.flow.internal
  &redirect_uri=http://127.0.0.1:8765/callback
  &state=<random>
  &code_challenge=<BASE64URL(SHA-256(code_verifier))>
  &code_challenge_method=S256

Every parameter is required:

  • PKCE with S256. Hydra refuses a public client without it, and refuses the plain method.
  • audience, set to one the client is registered for. Without it, consent is refused, because a token with no audience is one no service accepts.

The person sees the sign-in app's screens: password or Google, and registration if they are new. There is no consent screen. The sign-in app accepts consent on the server for first-party clients. It remembers nothing, so prompt=none does not work.

Exchange the code for tokens

Hydra redirects to redirect_uri with code and state. Check that state matches the one you stored, then exchange the code:

curl -X POST {issuer}/oauth2/token \
  -d grant_type=authorization_code \
  -d client_id=flow-sample-web \
  -d code=<code> \
  -d redirect_uri=http://127.0.0.1:8765/callback \
  -d code_verifier=<code_verifier>

The response holds:

  • access_token: a JWT that expires after one hour. Its sub is the person's FID: The id of a principal. For a person it is their Kratos identity id, a lowercase UUID; for a service it is its OAuth2 client_id. It arrives as the verified sub claim of a Hydra access token and becomes the actor on the request. and its aud is the audience you asked for.
  • id_token: proves who signed in to your app. Keep it for sign-out. Never send it to a service; services refuse it.

There is no refresh token. When the access token expires, send the person through the authorization step again. While their sign-in session is still active they are not asked to sign in again.

Call services with the access token

Send the access token on every request:

GET /v1/... HTTP/1.1
Authorization: Bearer <access_token>

A 401 means the token is missing, expired or not for this service: get a new token. A 403 means the person is signed in but has no grant that allows the action. Grants are added in the user admin app. The token itself carries no roles, so there is nothing in it for the app to read or check.

Keep tokens on the server where you can. A Next.js app can hold them in an encrypted, HttpOnly session cookie and call services from server code, so the token never reaches browser JavaScript. The sample web app does this; see frontend/sample-app/src/server/session.ts and frontend/sample-app/src/server/sampleService.ts.

The same token works at every service, so whoever holds it can act as the person anywhere until it expires. Never log it, never put it in a URL, and send it only to the platform's own services.

Show who is signed in

The access token names the person only by their FID: The id of a principal. For a person it is their Kratos identity id, a lowercase UUID; for a service it is its OAuth2 client_id. It arrives as the verified sub claim of a Hydra access token and becomes the actor on the request.. It carries no email and no name, and neither does the identity service, which stores no personal data. Kratos holds them. Ask the profile service for them, with the same token:

GET /v1/me/profile HTTP/1.1
Authorization: Bearer <access_token>
{
  "fid": "9f425a8d-7efc-4768-8f23-7647a74fdf13",
  "email": "[email protected]",
  "email_verified": true,
  "name": {"first": "Pat", "last": "Lee"}
}

The body is inside the platform's usual payload.data envelope. The profile service reads the person's own Kratos identity each time it is asked, so a changed name shows at once. It always answers for the token's sub: it takes no parameters, and there is no way to ask for someone else. A name part that is not set is an empty string. Only a person has a profile, so a machine client's token gets a 403.

On dev it is at https://authn.dev.apps-and-services.com/v1/me/profile, a route on the sign-in host; locally the profile service is at http://127.0.0.1:8139. The sample web app calls it in frontend/sample-app/src/server/profileService.ts and shows "Signed in as" with the email. If the call fails, show the rest of the page without it. The rules are in services/profile/README.md.

Decide what to show

Ask the identity service what the person may do, with the same token:

GET /v1/authz/me HTTP/1.1
Authorization: Bearer <access_token>
{
  "fid": "9f425a8d-7efc-4768-8f23-7647a74fdf13",
  "kind": "person",
  "can": [
    {"scope_type": "building", "scope_id": "0193b000-0000-7000-8000-00000000a001",
     "actions": ["sample::CreateWorkOrder", "sample::ListWorkOrders"]},
    {"scope_type": "global", "scope_id": "*", "actions": ["sample::CreateWorkOrder"]}
  ]
}

The body is inside the platform's usual payload.data envelope. Show a button only when its action is listed at the right building or at global. The answer is what the person's grants allow, before any rule about a particular record, so a service can still refuse a call: always handle a 403. It names no roles, and a new grant shows in it at once.

On dev it is at https://authn.dev.apps-and-services.com/v1/authz/me, a route on the sign-in host; locally identity is at http://127.0.0.1:8134. The sample web app calls it in frontend/sample-app/src/server/identityService.ts. The rules are under "What may I do" in services/identity/README.md.

Sign the person out

Send the browser to Hydra's logout endpoint with the ID token and a registered post_logout_redirect_uri:

GET {issuer}/oauth2/sessions/logout
  ?id_token_hint=<id_token>
  &post_logout_redirect_uri=http://127.0.0.1:8765/logged-out
  &state=<random>

The sign-in app ends both the Kratos session and the Hydra session, then Hydra sends the browser to post_logout_redirect_uri. Clear the app's own session too. Without a valid id_token_hint, the person is asked to confirm instead of being signed out at once. The details are in frontend/authn-app/README.md.

Mobile apps

A native app is also a public client and uses the same flow. It opens the authorization URL in the system browser session (ASWebAuthenticationSession on iOS), and its redirect URI is a custom scheme or universal link registered on the client. No mobile client is registered yet.

On this page