Ktor gives you a minimal server and lets you add each security control as a plugin, so securing a Ktor service means choosing, installing and configuring the right plugins. Authentication, session signing, CORS, HSTS, rate limiting and proxy header handling are all separate modules with defaults that suit development, not production. This guide covers server-side Ktor (the JVM server framework from JetBrains): how its authentication providers and sessions work, how to model roles, how to handle the signing keys, OAuth client secrets and API keys a Ktor service holds, what to log, and a hardening baseline. Details were checked against the Ktor documentation for version 3.6.1 on 8 November 2026; Android and mobile Kotlin code is covered in the Kotlin and Android Secure Coding Guide.
This is an independent guide from NHI Mgmt Group, based on JetBrains' public documentation as of November 2026. It is not sponsored or reviewed by JetBrains. Product features change, so confirm settings against JetBrains' current documentation.
Key takeaways
- Nothing is protected until it sits inside an authenticate block. The Authentication plugin only checks routes wrapped in
authenticate("name")(orauthenticateWithin the typed API); a route declared outside is public. - JWT validation needs issuer, audience and algorithm pinned. Build the verifier with
withIssuerandwithAudience, pin the algorithm, and reject ID tokens used as access tokens (the experimental Oidc plugin does this for you). - Client-side sessions must be signed and usually encrypted. Use
SessionTransportTransformerEncrypt, setsecure = true,HttpOnlyandSameSite, and never shipSessionStorageMemoryto production. - Proxy headers are attacker input unless only the proxy can reach you. Install
XForwardedHeadersonly when the app accepts connections solely from your reverse proxy, and configure whichX-Forwarded-Forentry to trust. - The service's own credentials are non-human identities. JWT signing keys, session keys, OAuth client secrets and API keys belong in a secrets manager injected through environment variables, with an owner and a rotation date.
How identity and access work in Ktor
Ktor separates three ideas. A credential is what the client presents (a username and password, an API key, a JWT). A provider turns that credential into a principal through a validate function that returns a principal object on success or null on failure. Routes then require one or more named providers. Provider names must be unique and only one provider may be unnamed, so a service with both browser users and machine clients normally registers, for example, "auth-session" and "auth-jwt" and attaches each to the routes it serves.
| Provider | Credential and transport | Typical use | Security notes |
|---|---|---|---|
| Basic | Base64 username and password in Authorization | Internal tools, legacy clients | Ktor's docs say it is not recommended without HTTPS; no logout, credentials sent on every request |
| Digest | Hashed credentials in Authorization | Legacy interoperability | Requires the server to hold a digest of the password; rarely the right choice for new services |
| Form | Username and password posted from an HTML form | Browser login, usually followed by a session | Pair with session authentication; add rate limiting and lockout logic yourself |
| Session | Signed or encrypted cookie, or a custom header | Browser sessions after login | Strength depends entirely on session transformers and cookie attributes |
| Bearer and JWT | Token in Authorization: Bearer | APIs, service to service | validate is mandatory (initialisation throws IllegalArgumentException without it); pin issuer, audience, algorithm |
| API Key | Secret in a header, typically X-API-Key | Partner and machine clients | The key is identifier and secret at once; compare hashes, scope and expire keys |
| OAuth | Authorisation code flow with an external provider | Sign in with an identity provider | The Ktor app holds a client secret; typed oauth2Session flows create login and callback routes |
| OpenID Connect (experimental, JVM) | Issuer discovery, JWKS, PKCE | APIs and browser sign-in against an OIDC provider | Checks signature, issuer, audience and expiry; audience is required and must not be empty |
| LDAP | Bind against a directory | Internal apps on a corporate directory | Use LDAPS; the bind account is a service credential |
JWT: the details that decide whether it is safe
- Verifier: for HS256 pass a
JWTVerifierbuilt withJWT.require(Algorithm.HMAC256(secret)).withAudience(...).withIssuer(...); for RS256 pass aJwkProviderpointing at the issuer's JWKS endpoint. Building the verifier around one algorithm object is what prevents a token signed with a different algorithm being accepted. - Payload validation: use
validateto check claims your API depends on (subject format, scope, tenant) and return your own principal type. Returnnullfor anything unexpected rather than a partially trusted principal. - Challenge: configure
challengeto return a plain 401 without explaining which check failed. The OIDC plugin documentation notes it deliberately does not distinguish a missing token from an expired one. - HS256 vs RS256: HS256 means every service that verifies tokens can also mint them. Prefer RS256 or ES256 with a JWKS endpoint whenever more than one service verifies tokens. See the JWT Security Guide.
Sessions
The Sessions plugin passes data in a cookie (suits HTML applications) or a custom header (suits APIs), and stores the payload either on the client or on the server with only an ID sent to the client.
| Choice | Option | When it fits | Risk to manage |
|---|---|---|---|
| Payload location | Client (cookie carries data) | Stateless horizontal scaling, small payloads | Must be signed and encrypted; cannot be revoked server-side before expiry |
| Payload location | Server (cookie carries ID) | Logout and revocation must be immediate | Needs shared storage across instances; SessionStorageMemory is development only |
| Protection | SessionTransportTransformerMessageAuthentication | Data is not sensitive but must not be altered | HmacSHA256 by default; contents readable by the user |
| Protection | SessionTransportTransformerEncrypt | Any user ID, role or token in the payload | AES plus HmacSHA256 by default; encryption changed in 3.0.0, so migrations need backwardCompatibleRead |
| Transport | Cookie | Browser sessions | Set secure = true, httpOnly, SameSite via cookie.extensions, short maxAge |
| Transport | Header | APIs and native clients | Add the header to CORS allowHeader/exposeHeader deliberately; never expose it to any host |
OWASP's Session Management Cheat Sheet recommends at least 64 bits of entropy from a CSPRNG for session IDs, regenerating the ID after login or privilege change, and enforcing idle and absolute timeouts on the server. In Ktor that means calling call.sessions.clear<T>() and then setting a fresh session at login, and storing an issued-at time in the session so a route interceptor can enforce the absolute lifetime. See the Session Fixation Guide.
Admin roles and privileged access
Authentication in Ktor answers who the caller is; authorisation is yours to build. There are two supported patterns:
- Typed authentication with roles: define an enum implementing
AuthenticationRole, wrap a scheme withwithRoles { user -> roleService.resolveRoles(user.id) }and protect routes withauthenticateWith(roleAuth, roles = setOf(Role.Admin)). An unauthenticated caller gets 401; an authenticated caller missing a role gets 403. The caller must hold every role in the set, not any one of them, which matters when you combine roles.withRolesruns on every request after authentication, so roles loaded from a database reflect revocation immediately (cache with care). - Custom plugin on the AuthenticationChecked hook: the documented way to add authorisation logic (scope checks, tenant isolation, object ownership) that runs after authentication and before the handler.
Whichever you use, put the check at the route group, not inside each handler. Object-level checks (does this user own order 4711?) still belong in the handler or service layer: route roles do not prevent broken object level authorisation, the first risk in the OWASP API Security Top 10 2023. See OWASP API Top 10 API1 Broken Object Level Authorization Guide and the Authorisation Models Guide.
- Separate admin surfaces: mount admin routes under a distinct path with their own provider and, where possible, a separate port or ingress restricted by network policy.
- Step-up for destructive actions: require a recent authentication timestamp in the session or token (
auth_timefrom OIDC) for deletes, exports and role changes. - Development shortcuts: search for
skipWhenconditions and debug routes before release. AskipWhenthat trusts a header or a local address can bypass a provider entirely.
Integrations, API tokens and service accounts
A typical Ktor service holds four kinds of machine credential, and each is a non-human identity with an owner, a scope and a lifetime.
| Credential | Where Ktor uses it | Storage | Rotation approach |
|---|---|---|---|
| JWT signing key (HS256 secret or RS256 private key) | Token issuance and verification | Secrets manager or KMS; injected via environment variable referenced from application.conf | Publish new key in JWKS with a new kid, sign with new, retire old after maximum token lifetime |
| Session sign and encrypt keys | Session transformers | Secrets manager; never in code (Ktor's docs warn against this explicitly) | Dual-key read during rollover; all sessions invalidated if a key leaks |
| OAuth or OIDC client secret | OAuth provider, OIDC browser login | Secrets manager | Rotate at the identity provider; prefer private_key_jwt or workload identity federation where supported |
| Inbound API keys | API Key provider | Store a hash, not the key; show the key once | Expiry date on every key, owner recorded, revoke on offboarding |
| Outbound credentials (database, LDAP bind, downstream APIs) | Ktor client, data layer | Secrets manager or platform identity | Short-lived where the platform supports it |
| TLS keystore passwords | ktor.security.ssl.keyStorePassword, privateKeyPassword | Environment variables | Rotate with the certificate |
- Configuration: HOCON
application.confsupports only the${ENV}substitution syntax. Commit a config file that references variables, never values; scan repositories for committed secrets because a committed config is credential exposure (ATT&CK T1552.001 Credentials In Files). - Inbound API keys: look up a key by a non-secret prefix and compare a hash of the remainder in constant time, rather than comparing the raw header to a stored plain value. Rate limit per key. See the API Key Management Guide.
- Service-to-service tokens: give each calling service its own client and audience, so a token stolen from one service cannot be replayed to another (ATT&CK T1550.001 Application Access Token).
Where identity fits: these credentials outlive developers and deployments, so inventory them with the rest of your machine identities. See the Secrets Management Guide.
Serialisation and input
Ktor's ContentNegotiation plugin supports JSON, XML, CBOR and ProtoBuf, with kotlinx.serialization, Gson or Jackson for JSON. kotlinx.serialization only deserialises classes marked @Serializable and resolves polymorphism from explicitly registered subclasses, which is a safer default. With Jackson, OWASP's Deserialization Cheat Sheet treats jackson-databind as safe with default configuration as long as polymorphism is not used: avoid default typing and class-name based type information on request bodies. Add the RequestValidation plugin for body constraints, set request size limits at the engine or proxy, and never bind request bodies straight into persistence entities that carry fields such as role or isAdmin (mass assignment). See the Insecure Deserialisation Guide.
Logging and detection
CallLogging logs requests at INFO by default, supports a filter and can put values into MDC (Mapped Diagnostic Context) so every log line for a call carries a request ID and principal ID. Authentication outcomes are not logged for you: log them yourself in validate and challenge.
| Event | Where to capture it | What it can reveal |
|---|---|---|
| Authentication failure | challenge handler (provider name, reason category, source IP after proxy handling) | Credential stuffing, expired keys still in use |
| Authorisation denial (403) | withRoles result or AuthenticationChecked plugin | Privilege probing, broken role mapping |
| Session created, renewed, cleared | Login and logout handlers | Session fixation, sessions that never end |
JWT rejected for issuer, audience or kid | Verifier exceptions surfaced in challenge | Token replay from another service, forged tokens (T1078, T1539) |
| Rate limit hit (429) | RateLimit plugin responses | Brute force, scraping, abusive API keys |
| Unhandled exception | StatusPages exception<Throwable> | Exploitation attempts against public endpoints (T1190) |
Do not log: the Authorization header, session cookies, API keys or request bodies containing credentials. Filter them before CallLogging writes the line. See the JWT and Session Tokens in Code and Logs Guide.
Metrics that show the controls work
- Route coverage: percentage of non-public routes inside an
authenticateorauthenticateWithblock (target 100%, enforced by a route-listing test). - Secret hygiene: count of signing keys, session keys and client secrets older than their rotation period.
- Authentication failure rate per provider and per API key, with alerts on sudden changes.
- Session lifetime: 95th percentile session age against your absolute timeout.
- Dependency lag: days between a Ktor security release and deployment.
Hardening baseline
| Plugin or setting | Production setting | Why |
|---|---|---|
| HttpsRedirect and SSL | Redirect HTTP to HTTPS, or terminate TLS at the proxy and keep internal hops encrypted | Basic, API key and bearer credentials are plaintext without TLS |
| HSTS | Install with a long maxAgeInSeconds; served only over HTTPS (ignored over HTTP) | Prevents downgrade on later visits (RFC 6797) |
| CORS | Explicit allowHost("app.example.com", schemes = listOf("https")); no anyHost() on credentialed APIs | Defaults allow GET, POST and HEAD; every extra method and header should be a decision |
| XForwardedHeaders | Install only behind a proxy that strips client-supplied headers; configure which entry to trust | By default the first X-Forwarded-For entry becomes origin.remoteHost, and the client controls it |
| RateLimit | Named limiters on login, token and expensive routes, keyed by IP after proxy handling or by API key | Token bucket with Retry-After on 429 |
| Sessions | Encrypt transformer, secure = true, SameSite=Lax or Strict, server storage for revocable sessions | Cookie theft and tampering |
| StatusPages | Generic error body; log the exception server-side | Stack traces leak versions and internals |
| DefaultHeaders | Override Server; add security headers (Content-Security-Policy, X-Content-Type-Options) for HTML responses | Reduces fingerprinting; browser hardening |
| Development mode | Off in production (the io.ktor.development property or development setting left false) | Ktor's docs say development mode affects performance and should not be used in production |
Rollout in phases
- Inventory: list every route and its provider by writing a test that walks the routing tree; mark each route public, user, admin or machine.
- Secrets first: move signing keys, session keys and client secrets into a secrets manager and reference them through environment variables; rotate anything that was ever committed.
- Tighten tokens and sessions: pin issuer, audience and algorithm; switch client sessions to the encrypt transformer. Session encryption changes invalidate existing cookies, so deploy in a low-traffic window or use
backwardCompatibleReadduring a 3.x migration. - Edge plugins: enable HSTS with a short max-age first and raise it after a week; restrict CORS; configure forwarded headers to match your proxy chain.
- Test: add integration tests that call every protected route without credentials (expect 401), with the wrong role (expect 403), with a token for another audience and with a tampered session cookie.
- Roll back safely: keep plugin settings in configuration rather than code so a bad CORS or rate limit can be reverted without a rebuild; keep the previous signing key in the JWKS until all tokens it signed have expired.
Decision guidance
- Small team, one service: delegate login to an identity provider through the OAuth or OIDC plugin, use server-side sessions in a shared store, and avoid issuing your own JWTs at all.
- Several services, one gateway: validate tokens at the gateway and again in each Ktor service with its own audience; never trust a header such as
X-User-Idset by the gateway unless the network path guarantees only the gateway can reach the service. - Public API with partners: OAuth client credentials with short-lived tokens beat static API keys; if you must issue keys, hash, scope and expire them.
Common misconfigurations
- Routes outside the authenticate block: a new route added at the top level of
routing {}is public. Catch it with the route-coverage test rather than review. - Hard-coded secrets in samples copied to production: the documentation examples use inline secrets for clarity; Ktor's own docs warn that secrets should not be stored in configuration as plain text.
- JWT verifier without audience: any token from the same issuer, including one meant for another API, is accepted. The same gap applies to accepting OIDC ID tokens as API access tokens. See the Token and Session Security Guide.
- In-memory or unencrypted sessions in production:
SessionStorageMemoryloses sessions on restart and does not share them across instances, which pushes teams to client-side sessions that are only signed, leaving user data readable. - anyHost() with credentials: combining permissive CORS with cookie sessions lets other origins drive authenticated requests. See the CORS Misconfiguration Guide.
- Trusting X-Forwarded-For from the internet: installing XForwardedHeaders on a directly exposed service lets clients pick their own IP, defeating IP rate limits and audit logs.
Framework mapping
| Ktor control | NIST SP 800-53 Rev. 5 | NIST CSF 2.0 | ATT&CK technique addressed |
|---|---|---|---|
| Authentication providers on every route | IA-2, AC-3 | PR.AA-01 | T1078 Valid Accounts |
| Roles and AuthenticationChecked authorisation | AC-3, AC-6 | PR.AA-05 | T1078 Valid Accounts |
| Encrypted sessions, secure cookies | SC-23 | PR.DS-02 | T1539 Steal Web Session Cookie |
| Signing keys and secrets management | SC-12, IA-5 | PR.AA-01 | T1552.001 Credentials In Files, T1550.001 Application Access Token |
| TLS, HTTPS redirect, HSTS | SC-8 | PR.DS-02 | T1539 Steal Web Session Cookie |
| RequestValidation, safe serialisation | SI-10 | DE.CM-09 | T1190 Exploit Public-Facing Application |
| RateLimit | SC-5 | DE.CM-09 | T1190 Exploit Public-Facing Application |
| CallLogging with MDC, auth event logs | AU-2, AU-12 | PR.PS-04 | T1078 Valid Accounts |
Practitioner checklist
- Run a test that lists every route and fails the build if a non-public route has no provider.
- Pin issuer, audience and algorithm in every JWT verifier; use JWKS with
kidfor asymmetric keys. - Use
SessionTransportTransformerEncrypt,secure = true, HttpOnly and SameSite on session cookies, and server storage when sessions must be revocable. - Regenerate the session at login and enforce idle and absolute timeouts server-side.
- Store signing keys, session keys, client secrets and API key hashes in a secrets manager, with an owner and rotation date for each.
- Restrict CORS to named HTTPS origins and install forwarded header plugins only behind a header-stripping proxy.
- Apply named rate limiters to login, token and expensive endpoints.
- Return generic errors through StatusPages and keep tokens and cookies out of logs.
- Avoid Jackson polymorphic typing on request bodies; prefer kotlinx.serialization with registered subclasses.
- Track Ktor releases and upgrade within your patch window.
Standards and references
- Ktor: Authentication and authorization
- Ktor: JSON Web Tokens
- Ktor: Sessions
- Ktor: Forwarded headers
- Ktor: CORS
- OWASP: Session Management Cheat Sheet
- OWASP: Deserialization Cheat Sheet
- NIST: SP 800-53 Rev. 5
Related NHI Mgmt Group resources: Kotlin and Android Secure Coding Guide · JWT Security Guide · Secrets Management Guide · API Key Management Guide