OpenID Connect browser login
This topic explains how to use OpenID Connect to sign users in from a browser, manage their sessions, and handle logout and token refresh.
How the flow works
The user opens
/oidc/{name}/login.Ktor redirects the user to the provider, with a
state, anonce, and a PKCE challenge.The user signs in with the provider and approves the requested scopes.
The provider sends them back to
/oidc/{name}/callbackwith an authorization code.Ktor exchanges the code for tokens, validates the ID token, and stores a session.
The
onAuthenticatedhandler runs.
The plugin creates the following routes:
Route | Method | Created |
|---|---|---|
|
| Always |
|
| Always |
|
| When you call |
|
| When you call |
Register the callback route with your provider as an allowed redirect URI.
Sign users in
To handle a successful sign-in, configure OAuth credentials and use the onAuthenticated handler:
The onAuthenticated handler runs once at the end of a successful callback, after the session has been stored. Without this handler, a successful login responds with 200 OK and an empty body.
The scopes property defaults to listOf("openid", "profile", "email"). Assigning a value replaces the default list rather than adding to it, and the resulting list must still contain openid:
Set fetchUserInfo = true to retrieve claims that the provider does not include in the ID token. This adds a request to the UserInfo endpoint on every login.
Customize the routes
You can customize all generated paths. The loginUri and redirectUri properties accept a URLBuilder block, while logout() and refresh() accept a path:
The redirectUri property must match a redirect URI registered with your provider. Update the registered redirect URI when you change this value.
None of these builders support query parameters. The plugin rejects paths that include them.
Protect routes with the session
The provider.session scheme authenticates users with an existing session. Inside the block, call.principal is an OidcToken.Id:
To work with an application-specific principal instead of the OIDC token, map the scheme with mapPrincipal():
Configure the session
Sessions are enabled by default. Use the sessions { } block to configure the cookie name or session storage:
The cookie name defaults to {PROVIDER_NAME}_SESSION. The plugin sets HttpOnly, SameSite=Lax, and Secure outside development mode. Override these settings only when required.
The transport is always SessionTransportType.CookieId and cannot be changed. Only the session ID is sent to the browser. The ID token, access token, and refresh token remain server-side in storage.
Session storage defaults to SessionStorageMemory(), which loses all sessions when the application restarts and does not share sessions between instances. Configure persistent or shared storage before deploying to production.
Protect against CSRF
The routes generated by the plugin are protected by origin checks:
This protection is enabled by default, so configure this block only when you need to change it.
You can disable CSRF protection with the disableCsrfProtection() function. However, the logout and refresh routes accept POST requests from browsers that include the session cookie, so these routes should remain protected against CSRF.
Sign users out
To sign users out, use the logout() function:
A POST request to /oidc/google/logout clears the session and responds with 303 See Other, redirecting the user to the provider's end_session_endpoint.
The provider must advertise end_session_endpoint in its discovery document. Ktor checks this when routes are registered, so a provider without it makes your application fail to start rather than failing at sign-out time. If your provider does not support this endpoint, clear the session directly instead of calling logout().
Pass a handler to run additional logic or provide a custom response instead of redirecting:
Signing out does not revoke the refresh token at the provider.
Keep sessions fresh
ID tokens expire. By default, the plugin does not refresh them. Once the token is past its exp value, the session is cleared and the user must sign in again.
To refresh tokens automatically, configure a refresh strategy:
The refreshed token must have the same sub value as the existing token. Otherwise, the refreshed token is discarded.
For automatic refreshes, use OidcTokenRefreshStrategy.Auto. For custom refresh behavior, use OidcTokenRefreshStrategy.Custom:
Return token to keep the existing session, a new OidcToken.Id to replace the stored session, or null when no refreshed token is available. If the callback returns null or throws an exception, the session remains available while the current token is valid and is cleared once it expires. To end a session immediately, clear it with the Sessions plugin instead.
The callback runs for every request authenticated with the session scheme, so keep its work lightweight. Use the now parameter instead of reading the clock directly, so all time comparisons within the request use the same value.
The now parameter and claims.expiresAt are kotlin.time.Instant, which is experimental. A strategy that compares them needs @OptIn(ExperimentalTime::class) in addition to the opt-in required by the plugin.
You can also add a route for on-demand refresh:
A POST request to /oidc/google/refresh responds with 200 OK on success and 401 Unauthorized when the session cannot be refreshed. On a 401 response, the existing session remains unchanged until its token expires.
Refresh tokens manually
For cases that do not use the generated refresh route, such as a scheduled job or a refresh before calling a downstream API, call the refreshToken() function on the provider:
OidcTokenRefreshResult carries the raw token response: accessToken, refreshToken, expiresIn, tokenType, and scope. The idToken property is null when the provider does not return an ID token, so check it before storing a session.
The provider can throw the following exceptions during refresh:
ResponseExceptionwhen the provider rejects the request.OidcTokenRejectedExceptionwhen the returned tokens fail validation.OidcSigningKeyUnavailableExceptionwhen the ID token cannot be verified. For more information, see Handle server-side validation failures.
Concurrent calls with the same refresh token share a single request to the provider. The result is reused for tokenRefreshCacheTtl, so no additional synchronization is required.
Sign in without a session
If you manage your own session or issue your own token, disable the plugin's session support:
When sessions are disabled, onAuthenticated is required. The provider.session, logout(), and refresh() functions are not available in this mode.
Use multiple providers
Register one identity provider for each issuer. Each provider has its own routes, cookies, and authentication schemes:
In the above example, the login page links to /oidc/google/login and /oidc/github-idp/login. To allow either provider to authenticate the same route, map both to a shared principal type and use the authenticateWithAnyOf() function.
Request scoped tokens
Use the resourceIndicators property (RFC 8707) to request an access token for a specific API:
The value should match the identifier the API publishes in its protected resource metadata.
Understand PKCE
The authorization code returns through the user's browser and passes through software outside your control. Another application, such as a malicious app registered for the same URL scheme or a logging proxy, could get the code and attempt to redeem it for tokens.
PKCE (RFC 7636) prevents the authorization code from being used without an additional secret. Before redirecting the user, Ktor generates a random secret called the verifier and sends only its SHA-256 hash, the challenge, to the provider. When Ktor later exchanges the code, it presents the verifier, and the provider checks that it hashes to the challenge it stored.
The verifier is not sent to the provider during the authorization request and cannot be read by browser scripts. Ktor stores it alongside the state and nonce values in the AES-256-GCM encrypted cookie the plugin sets, so the browser holds only ciphertext it has no key for. Decrypting the cookie requires your stateEncryptionKey.
Ktor uses PKCE for every login. The codeChallengeMethod defaults to CodeChallengeMethod.S256, and only S256 is supported. Custom challenge methods are rejected. Because the verifier is stored in that cookie, the login has a time limit.
Handle sign-in errors
Respond to a failed login
The onAuthenticationFailed handler runs when a callback cannot be completed:
The handler receives an AuthenticationFailedCause, not an exception. AuthenticationFailedCause.Error provides a message with details about the failure.
Without a handler, a failed login responds with a 401 Unauthorized and an empty body. For browser applications, you can redirect the user to the login route to start a new authentication flow.
The handler should produce a response. If it does not, Ktor falls back to the challenge the failure registered, or responds with a 401 Unauthorized when no challenge is available.
When the token endpoint rejects the authorization code as invalid_grant, which happens when a code has expired or has already been used, a redirect back to the provider is registered as the fallback. If the handler does not respond, the login flow starts again.
Restarting the flow is useful when, for example, a user reloads the callback page and attempts to reuse the same code. The risk is that it repeats. However, repeated invalid_grant responses can create a redirect loop. This can happen when every authorization code fails, for example because the client secret is incorrect or the system clock causes codes to appear expired.
To prevent the fallback redirect, respond inside the handler. To allow a single retry, track whether a retry has already occurred:
A short-lived cookie records whether a retry has already occurred. A second failure returns an error instead of redirecting again. The cookie is cleared on that error, and expires automatically after two minutes so the next login is not affected.
The login window is 10 minutes
The state, nonce, and PKCE verifier live in an encrypted cookie that expires after 10 minutes. This is not configurable.
A user who opens the login page, walks away, and comes back an hour later gets a failed callback. So does a user whose login was in progress when the application , unless you configured stateEncryptionKey:
The key must be exactly 32 bytes. Without an explicit key, the plugin generates a new key for each process. As a result, in-progress logins fail after an application restart and cannot continue across instances behind a load balancer. You can use OidcStateEncryptionKey.rotating(current, previous) to change the key without invalidating login flows that are already in progress.
An expired login and a forged callback produce the same failure, so the handler cannot distinguish between them. This is why redirecting back to the login route is the default.
Authentication failure causes
Cause | Typical reason |
|---|---|
The provider returned | The user declined consent |
The state cookie is missing or expired | The 10-minute window passed, or a restart without |
The state cookie cannot be decrypted | A forged callback, or a rotated key |
| A mix-up between providers (RFC 9207) |
| A replayed ID token |
| The ID token does not match the access token |
The response contains no | The provider is not running an OIDC flow |
ID token validation failed | Signature, issuer, audience, |
The token endpoint returned an error | An expired or reused authorization code |
Handle provider availability errors
Some failures never reach onAuthenticationFailed and surface as 500 Internal Server Error instead. For more information, see Handle server-side validation failures.
This can occur during automatic token refresh. With OidcTokenRefreshStrategy.Auto a refresh runs while handling an ordinary request. If the provider is unavailable, the request can therefore fail with a 500 response.
What happens to the session depends on why the refresh failed:
Failure | Session | Result |
|---|---|---|
The refreshed token is invalid | Cleared |
|
The provider is unreachable or returns an error | Kept, unless already expired | The exception propagates, so |
No ID token in the response, or | Kept while still valid, cleared once expired | Works until the old token expires |
After a session is cleared, the next protected request is unauthenticated and the user must sign in again.