Skip to main content

Authorization Endpoint

The authorization endpoint starts the OAuth 2.0 Authorization Code flow. When a user clicks "Sign in with LumoAuth" in your application, you redirect them here. LumoAuth handles authentication and consent, then redirects back to your application with an authorization code that your backend exchanges for tokens at the Token Endpoint.

GET /orgs/{orgId}/api/v1/oauth/authorize

The Authorization Code flow is the standard OAuth 2.0 sign-in flow: the browser is sent to LumoAuth, and your backend exchanges the short-lived code it gets back for tokens, so tokens never travel through the browser's address bar. Terms used on this page (PKCE, nonce, scopes, claims) are explained in the glossary.

Do this​

  1. Generate a random state (a value that lets you recognise your own callback) and a PKCE code_verifier (a one-time secret that protects the sign-in code; see PKCE); store both in the user's session.
  2. Redirect the browser to /authorize with response_type=code, your client_id, the exact redirect_uri you registered, scope=openid profile email, state, code_challenge and code_challenge_method=S256.
  3. In your callback, reject the response unless iss equals the discovery issuer and state equals the stored value.
  4. POST the code and code_verifier to the token endpoint.

The full request is in Example Request; a callback check is in Validate the callback.

How It Works​

  1. User is shown a login form (or social login options).
  2. If the organization's MFA policy asks for it, they complete a second factor (or pass the acr_values step-up).
  3. User reviews and approves the permissions your app is requesting.
  4. LumoAuth redirects back to your app with an authorization code.
8 / 8
  1. Client App to User-Agent: Redirect to /authorize
  2. User-Agent to Auth Server: GET /authorize
  3. Auth Server to Resource Owner: Login & Consent Form
  4. Resource Owner to Auth Server: Approves Request
  5. Auth Server to User-Agent: 302 Redirect (Auth Code)
  6. User-Agent to Client App: Callback (code, state, iss)
  7. Client App to Auth Server: POST /token (code)
  8. Auth Server to Client App: Access Token
Why Use This Flow?

In the Authorization Code flow, the user's credentials are entered at LumoAuth and never reach your application. Your application only ever sees the authorization code, which it exchanges for tokens over a back-channel.

Request Parameters​

ParameterRequiredDescription
client_idYesYour application's public identifier. The client must be active; a disabled client is treated as unknown.
redirect_uriYesURL where LumoAuth will send the authorization code. Must be a character-for-character match with a registered URI (see Redirect URI rules).
response_typeYesMust be code for the Authorization Code flow
scopeNoSpace-separated list of permissions (e.g., openid profile email)
stateRecommendedRandom string to prevent CSRF attacks. Always verify this in your callback.
code_challengeRecommendedPKCE code challenge (SHA-256 hash of code_verifier, base64url encoded)
code_challenge_methodIf PKCEMust be S256. The plain method is intentionally not accepted.
nonceIf OIDCRandom string to prevent replay attacks. Returned in the ID token.
promptNologin forces re-authentication; none requires an already-authenticated session and fails with login_required if the user is not signed in
response_modeNoHow the response is delivered: query (default for code), fragment, form_post, or a JARM mode — jwt, query.jwt, fragment.jwt, form_post.jwt (see Signed responses (JARM)). form_post is only accepted for http(s) redirect URIs.
requestNoA signed authorization request (JAR): the authorization parameters carried as a signed JWT so they cannot be altered in transit (see Signed request objects (JAR))
request_uriNoOnly the urn:ietf:params:oauth:request_uri:… value returned by PAR. Any other value is rejected with request_uri_not_supported — request objects are never fetched by reference.
providerNoA social provider hint (for example google) that sends the user straight to that identity provider instead of the login page.
acr_valuesNoSpace-separated authentication context the sign-in must meet, for example urn:lumoauth:acr:mfa:phr. See Authentication context.
max_ageNoMaximum age in seconds of the user's last sign-in; an older session must sign in again (with prompt=none: login_required).
login_hintNoE-mail address to pre-fill on the sign-in page.
id_token_hintNoA previously issued ID token. With prompt=none, the signed-in user must match its sub, otherwise login_required.
resourceNoIdentifier of the API the token is for (its audience); may be repeated. See Resource indicators.

With PKCE (Proof Key for Code Exchange) the client generates a random secret (the code verifier) and sends a hash of it when starting the authorization flow, then the real secret when exchanging the code. An attacker who intercepts the authorization code cannot redeem it without the verifier. PKCE is required for public clients (SPAs, mobile apps) and recommended for everyone.

Example Request​

GET /orgs/acme-corp/api/v1/oauth/authorize?
response_type=code&
client_id=abc123def456&
redirect_uri=https://myapp.com/callback&
scope=openid%20profile%20email&
state=xyzSecureRandom123&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
HTTP/1.1
Host: app.lumoauth.dev

Successful Response​

After the user authenticates and approves, LumoAuth redirects to your redirect_uri with the authorization code, your state, the issuer and (for http(s) redirect URIs) a session_state:

HTTP/1.1 302 Found
Location: https://myapp.com/callback?
code=SplxlOBeZQQYbYS6WxSbIA&
state=xyzSecureRandom123&
iss=https%3A%2F%2Fapp.lumoauth.dev%2Forgs%2Facme-corp%2Fapi%2Fv1&
session_state=1f3c...a9.7b2e0c4d1a9f3e55
ParameterMeaning
codeSingle-use authorization code, valid for 10 minutes (60 seconds for FAPI 2.0 clients)
stateYour value, echoed back
issThe issuer identifier (which LumoAuth server answered). Byte-identical to the discovery issuer. Present on success and error responses.
session_stateOpenID Connect Session Management value for the check_session_iframe. Store it if you monitor the session; see Logout.

Validate the callback​

// discovery was fetched once from /.well-known/openid-configuration
const params = new URL(request.url).searchParams;

if (params.get('iss') !== discovery.issuer) {
throw new Error('iss mismatch: possible mix-up attack, do not redeem the code');
}
if (params.get('state') !== session.oauthState) {
throw new Error('state mismatch: possible CSRF, do not redeem the code');
}
if (params.get('error')) {
throw new Error(`${params.get('error')}: ${params.get('error_description')}`);
}
// safe to POST params.get('code') + session.codeVerifier to the token endpoint
Standard

The issuer identifier in the authorization response standard adds the iss parameter to authorization responses so a client that works with more than one authorization server cannot be tricked into sending a code to the wrong one (a "mix-up" attack). To comply, compare iss with the discovery issuer byte-for-byte before redeeming the code. LumoAuth advertises authorization_response_iss_parameter_supported: true.

Verify the State

Always compare the state parameter in the callback with the one you sent. If they don't match, the request may be a CSRF attack — reject it.

Redirect URI rules​

redirect_uri is compared to the client's registered URIs with an exact string match: same scheme, host, port, path and query. Wildcards, prefix matches and "any port on localhost" are not supported. Register every variant you use.

URIAcceptedNotes
https://myapp.com/callbackYesThe normal case
http://127.0.0.1:49152/callback, http://localhost/cb, http://[::1]/cbYes, for native clientsLoopback redirect for desktop and CLI apps, as the native-apps best practice recommends. Web clients must use https even on localhost.
com.example.app:/callbackYes, for native clientsPrivate-use (reverse-DNS) scheme for installed apps
http://myapp.com/callbackNoPlain http on a non-loopback host
javascript:, data:, vbscript:, blob:, file:, about:NeverThese execute or read local content when rendered as a link or form action, so they are refused at registration time everywhere (portal, admin API, dynamic registration)

post_logout_redirect_uris follow the same rules. The presentation URIs (client_uri, logo_uri, policy_uri, tos_uri) must be http or https.

Standard

OAuth for native apps explains why installed apps cannot keep a secret and how they should receive the callback: a loopback http listener on 127.0.0.1, localhost or [::1], or a private-use URI scheme. The core OAuth 2.0 standard requires the server to compare the redirect URI with the registered value; LumoAuth uses exact matching as recommended by the OAuth 2.0 Security Best Current Practice. To comply, register the exact URI and always use PKCE.

Hosted login page​

When the user is not signed in, /authorize shows a sign-in page. Which page depends on the client:

  • Default: the organization's standard login page at /orgs/{orgId}/login, with the user sent back to /authorize afterwards.
  • Custom login page enabled (Applications → your app → Branding → Login page → Enable custom login page): a branded page hosted at /authorize that posts credentials to POST /orgs/{orgId}/api/v1/oauth/login/submit.

The hosted submit endpoint is protected the same way as the standard login form:

ProtectionBehaviour
CSRFThe page embeds a _csrf_token (token id oauth_login); a request without a valid token is sent back to the login page.
Client eligibilityclient_id must belong to this organization, be active, and have the custom login page enabled. Anything else is refused before the password is checked.
ThrottlingPer-IP and per-email rate limit (5 per 15 minutes).
Lockout5 consecutive failures lock the account for 15 minutes.
CAPTCHAWhen the organization enabled CAPTCHA for sign-in, the form must carry a valid captcha_token; otherwise the user is sent back with the provider's message and no password is checked.
Adaptive riskA sign-in that adaptive authentication scores as blocked is refused after the password check, exactly as on the standard login form.
One answer for every failureWrong password, unknown e-mail, throttled and locked all produce the same "Invalid credentials" message and the same redirect.
LumoAuth operatorsCannot sign in through an organization's page unless allow_root_admin_login has been enabled for that organization at its request.
Organization bindingA browser already signed in to another organization is treated as signed out: /authorize shows this organization's login page instead of issuing a code for the foreign user. The device-flow verification page does the same, and the token endpoint answers invalid_grant for a code or refresh token whose user is not a member of the client's organization.

JSON login for your own sign-in form​

Applications that render their own form can post credentials to POST /orgs/{orgId}/api/v1/oauth/login/json. It establishes the browser session exactly like the hosted page and never returns tokens; after complete you send the browser to /authorize, which issues the code without showing a login screen.

The request must be a real same-origin JSON call, so a cross-site HTML form cannot sign a browser in to an attacker's account (login CSRF):

  • Content-Type: application/json is required.
  • If the browser sends Origin, it must be the LumoAuth host itself, an origin allow-listed by the platform, or an allowed_origins entry of one of the organization's OAuth clients. Origin: null is refused. Without Origin, a Sec-Fetch-Site of cross-site or same-site is refused.
  • When CAPTCHA is on for sign-in, include the token as captcha_token in the JSON body.
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/login/json \
-H "Content-Type: application/json" \
-c cookies.txt -b cookies.txt \
-d '{"email":"alice@acme.com","password":"correct horse battery staple"}'
HTTPstatusWhat to do
200completeSigned in. Redirect the browser to /authorize with your usual parameters.
200mfa_requiredSend the browser to challenge_url (/login/mfa-challenge) to finish the second factor, then continue to /authorize.
400invalid_requestemail or password missing or empty, the body is not application/json, or the request is cross-origin.
400captcha_requiredCAPTCHA is on for sign-in and captcha_token is missing or was rejected. message is the text to show; render the widget and retry.
401invalid_credentialsWrong password, unknown account, or the account is locked. The response is identical in all three cases.
403blocked / inactiveThe account is blocked by an administrator or not active, or (blocked) adaptive authentication refused this sign-in as too risky.
404not_foundUnknown or inactive organization.
429rate_limitedPer-IP or per-email budget exhausted. Back off; retry after the window.

The same throttling, lockout, CAPTCHA and adaptive-risk rules as the hosted page apply. See Email & password for the account-side behaviour.

Error Response​

If the user denies access or an error occurs, LumoAuth redirects with an error (and iss):

HTTP/1.1 302 Found
Location: https://myapp.com/callback?
error=access_denied&
error_description=The+user+denied+the+request&
state=xyzSecureRandom123&
iss=https%3A%2F%2Fapp.lumoauth.dev%2Forgs%2Facme-corp%2Fapi%2Fv1
Error CodeDescription
invalid_requestMissing or invalid parameters, code_challenge_method other than S256, or response_mode=form_post with a non-http(s) redirect URI
invalid_clientUnknown or disabled client_id, or mismatched redirect_uri (shown inline, never redirected)
access_deniedUser denied the authorization request
login_requiredprompt=none but no active session (or max_age exceeded)
interaction_requiredprompt=none but the requested acr_values needs an MFA step the session has not done
consent_requiredprompt=none but the user has not yet approved the requested scopes for this client
invalid_targetA resource value is malformed or not registered for the client (see Resource indicators)
unsupported_response_typeresponse_type is not code
invalid_request_object, request_not_supported, request_uri_not_supportedA signed request object failed verification, was encrypted, or request_uri was not a reference issued by PAR. Shown inline, never redirected
server_errorUnexpected server error

Authentication context (acr and amr)​

After the user signs in, ID tokens and access tokens carry the authentication context of the browser session:

  • amr (authentication method reference) lists the methods used: the first step (pwd password, mail magic link, fed social or federated login, hwk passkey, swk LumoAuth app push), the second factor's values (hwk/user passkey, swk/user push, otp authenticator app or email code, sms text message) and mfa once a second factor is satisfied. Example: ["pwd", "otp", "mfa"].
  • acr names the strongest MFA tier completed in the session. It is absent when no second factor was completed in this session (including sign-ins where a trusted device or adaptive low risk skipped the challenge).
acr valueTierSatisfied by
urn:lumoauth:acr:mfa:phr1 — phishing-resistantPasskey
urn:lumoauth:acr:mfa:strong2LumoAuth app push, or stronger
urn:lumoauth:acr:mfa3Authenticator app, or stronger
urn:lumoauth:acr:mfa:basic4Text message or email code, or stronger

These four values are published as acr_values_supported in the discovery document.

Requesting a level (step-up)​

Send acr_values on the authorization request, or register the client with default_acr_values (dynamic client registration). Besides the URNs above, LumoAuth accepts the shorthands phr / phrh (tier 1), mfa (tier 3) and http://schemas.openid.net/pape/policies/2007/06/multi-factor (tier 3). Other values are ignored.

  • acr_values is a preference list: the weakest listed level is the one enforced.
  • If the session already completed MFA at that tier or stronger, the request continues normally.
  • Otherwise the user is sent to the step-up page (/auth/step-up), which challenges a factor that meets the level and then resumes the authorization request. A user with no factor yet is sent to Secure your account first.
  • With prompt=none, a session that does not meet the level gets error=interaction_required instead of a redirect.
GET /orgs/acme-corp/api/v1/oauth/authorize?
client_id=app_abc123&
response_type=code&
scope=openid&
redirect_uri=https%3A%2F%2Fmyapp.com%2Fcallback&
acr_values=urn:lumoauth:acr:mfa:phr&
code_challenge=…&code_challenge_method=S256&state=…

Always check the acr claim in the returned token rather than assuming the request was honoured. For step-up inside an API rather than a browser redirect, use the MFA challenge API. See Multi-Factor Authentication for the factor tiers.

PKCE Implementation​

A complete PKCE example in Python:

import secrets
import hashlib
import base64

# Step 1: Generate code_verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32)

# Step 2: Create code_challenge (SHA-256 hash, base64url encoded)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')

# Step 3: Build authorization URL
auth_url = (
f"https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/authorize?"
f"response_type=code&"
f"client_id=YOUR_CLIENT_ID&"
f"redirect_uri=https://myapp.com/callback&"
f"scope=openid%20profile&"
f"state={secrets.token_urlsafe(16)}&"
f"code_challenge={code_challenge}&"
f"code_challenge_method=S256"
)

# Step 4: Store code_verifier in session (you'll need it later)
session['code_verifier'] = code_verifier

# Step 5: Redirect user to auth_url

Signed request objects (JAR)​

Clients that want the authorization parameters integrity-protected can send them as a signed authorization request (JAR, a JWT-secured authorization request) in the request parameter, either directly at /authorize or inside a PAR body.

GET /orgs/acme-corp/api/v1/oauth/authorize
?client_id=your_client_id
&response_type=code
&request=eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0.eyJpc3MiOiJ5b3VyX2NsaWVudF9pZCIsImF1ZCI6Imh0dHBzOi8vYXBwLmx1bW9hdXRoLmRldi9vcmdzL2FjbWUtY29ycC9hcGkvdjEiLCJyZXNwb25zZV90eXBlIjoiY29kZSIsIi4uLiI6Ii4uLiJ9.…

Rules the server enforces:

RuleDetail
SignatureVerified against the client's registered jwks / jwks_uri. Only asymmetric algorithms are accepted (RS256/384/512, PS256/384/512, ES256/384/512). none is rejected. HS* is rejected — client secrets are stored hashed, so an HMAC signature can never be verified.
algMust equal the client's registered request_object_signing_alg when one is set.
iss / audiss must be your client_id; aud must contain the issuer from the discovery document.
exp / nbfEnforced when present (60 s clock skew).
client_id / response_typeWhen present inside the object they must match the outer query parameters. client_id must always be sent as a plain parameter — it selects the verification key.
NestingThe object must not contain request or request_uri.
EncryptionEncrypted (JWE) request objects are not supported and are rejected with request_not_supported.

Claims in the request object take precedence over the plain query parameters, as the signed-request standard requires. A rejected object produces an inline invalid_request_object error page — the request is never redirected, because redirect_uri has not been validated at that point. Discovery advertises request_parameter_supported: true, require_signed_request_object: false and request_object_signing_alg_values_supported.

Signed responses (JARM)​

With a response_mode of jwt, query.jwt, fragment.jwt or form_post.jwt, the authorization response (success and error) is delivered as a single response parameter holding a JWT signed with the organization's signing key (JWT Secured Authorization Response Mode, so your app can verify that nobody altered the response on its way back):

https://app.example.com/callback?response=eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL2FwcC5sdW1vYXV0aC5kZXYvb3Jncy9hY21lLWNvcnAvYXBpL3YxIiwiYXVkIjoieW91cl9jbGllbnRfaWQiLCJleHAiOjE3MDAwMDA2MDAsImNvZGUiOiIuLi4iLCJzdGF0ZSI6ImFiYzEyMyJ9.…
ClaimValue
issThe issuer from the discovery document (byte-identical)
audYour client_id
iat / expIssued-at and expiry — responses are valid for 10 minutes
code, stateOn success
error, error_description, stateOn error

Verify the JWT against the organization's jwks_uri and check iss, aud and exp before trusting code. The generic jwt mode resolves to query.jwt for the code flow. The signing algorithm is that of the organization's signing key (authorization_signing_alg_values_supported in discovery); a registered authorization_signed_response_alg is validated but the organization key's algorithm is used. response_mode may also be pushed via PAR.

Common mistakes​

  • iss does not match what my library expects. The issuer is https://<host>/orgs/{orgId}/api/v1 on the organization path and the bare https://<custom-domain> on a verified custom domain. Read it from the discovery document of the entry point you actually use; do not build it by hand.
  • invalid_client on a client that worked yesterday. The client was disabled. Disabled clients are treated as unknown on every OAuth endpoint.
  • "redirect_uri mismatch" with a loopback listener on a random port. Matching is exact, including the port. Register the port you bind to, or bind to a fixed port.
  • response_mode=form_post with a custom-scheme redirect URI. Refused with invalid_request; a form can only post to http(s).
  • 400 invalid_request from /login/json with a valid body. The request was sent as a form or text/plain, or from an origin that is not registered on any of the organization's clients. Send Content-Type: application/json from your app's registered origin.
  • 400 captcha_required from /login/json. The organization turned on CAPTCHA for sign-in. Render the provider widget and send its token as captcha_token.
  • A user signed in to another organization is asked to log in again. By design: a session only counts for the organization it was created in.
  • Hosted login page shows "Invalid request" immediately. The client is inactive, belongs to another organization, or does not have Enable Custom Login Page switched on.

Next Steps​

After receiving the authorization code, exchange it for tokens at the Token Endpoint. See PKCE for a deeper dive and the integrator checklist for redirect URI, issuer and state validation.

Standards used on this page​

Plain nameStandard
OAuth 2.0 (the core sign-in and token protocol)RFC 6749
Proof Key for Code Exchange (PKCE)RFC 7636
OpenID Connect (OIDC)OpenID Connect Core 1.0
Signed authorization requests (JAR)RFC 9101
Pushed authorization requests (PAR)RFC 9126
Resource indicatorsRFC 8707
Issuer identifier in the responseRFC 9207
LogoutOpenID Connect Session Management
OAuth for native appsRFC 8252
Authentication method reference (amr) valuesRFC 8176
Signed authorization responses (JARM)OpenID JWT Secured Authorization Response Mode

See the standards reference for what each one covers and how LumoAuth uses it.