DEV Community

Cover image for What Happens When Your Authentication Is Misconfigured
Stefano Pinato
Stefano Pinato

Posted on Originally published at identitysuite.net

What Happens When Your Authentication Is Misconfigured

Six settings that decide whether an attack works

TL;DR: Some of the most common weaknesses in a login system are not bugs in a framework, they are settings: a redirect URI that matches too loosely, a client that is allowed to skip PKCE, a token that stays valid for a day. This article walks through six of them — what each one lets an attacker do, how to check it on your own server in a few minutes, and what the settings files that ship with IdentitySuite do about it. One of the six, CORS, is not locked down by default, and I say so where it comes up.

Why configuration is the weak point

OAuth 2.0 and OpenID Connect are flexible on purpose. The same server can issue tokens to a browser app, a phone, a backend service and a TV, and each of those needs different rules. The protocol leaves those rules to the person configuring the server, and a server enforces only what it has been told to enforce.

The IETF's current security guidance, RFC 9700 (Best Current Practice for OAuth 2.0 Security, published January 2025), reads largely as a list of settings: match this exactly, require that, do not use the other. Four of the six topics below — redirect URIs, PKCE, token lifetimes and the implicit flow — are addressed directly in it. The other two, CORS and client secrets, are deployment mistakes rather than protocol ones.

If you configure OpenIddict yourself, the pitfalls article covers the same ground from the code side. Here the angle is the attacker's: what does each mistake buy them, and how do you find out whether you have it?

1. Redirect URIs that match too loosely

After you sign in, the authorization server sends the authorization code to the client's redirect_uri. If the server accepts anything that merely resembles the registered value — a prefix, a wildcard, a different case, any path under the same host — the attacker gets to choose where the code goes.

What the attack looks like. The attacker builds a link to your real login page with a redirect_uri pointing somewhere they control, or at a page on your own domain that forwards visitors elsewhere. The user signs in on your genuine page, and the code lands with the attacker. RFC 9700 requires authorization servers to compare redirect URIs against the registered ones with exact string matching, with one exception: the port of a loopback address in a native app.

How to check. Take a URI you have registered and request it with small changes: an extra path segment, different letter case, an added query string, another port, a subdomain, http instead of https. Every variant must be refused before the login page appears. Then read through the registered URIs of your production clients and look for localhost, plain http and anything that looks like a pattern.

What IdentitySuite does. The client form stores each redirect URI as a complete absolute URI, one entry per URI; there is no field for a pattern. Matching is done by OpenIddict, which compares the values as case-sensitive strings and relaxes only the port of a loopback address, and only for clients registered as native applications. That is the behaviour RFC 9700 asks for. What exact matching cannot do is tell you that a registered URI should not be there: a leftover https://localhost entry on a production client is matched exactly like any other.

⚠️ Open redirectors cancel exact matching

If a page on your client, correctly registered as a redirect URI, forwards the browser to whatever address a query parameter says, the code can still be carried out through it. RFC 9700 tells clients not to expose such redirectors. Exact matching on the server is only half of the defence.

2. PKCE supported but not required

PKCE ties an authorization code to the client instance that started the flow. The client sends a hash of a secret value when it begins (code_challenge) and the secret itself when it redeems the code (code_verifier). A server that supports PKCE will honour it when a client uses it. A server that requires it will refuse the request when the challenge is missing. Those are different security properties.

What the attack looks like. Authorization codes leak in more places than people expect: another app registered for the same custom scheme on a phone, a browser extension, a proxy log, a compromised hop in a redirect chain. Without PKCE, whoever holds the code can exchange it for tokens. With PKCE required, a stolen code is useless without the verifier. If PKCE is only optional, the attacker can craft the sign-in link themselves and leave the challenge out. RFC 9700 says public clients must use PKCE and recommends it for confidential clients too.

How to check. Send an authorization request that has everything except code_challenge:

https://id.example.com/connect/authorize?client_id=spa&response_type=code
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
    &scope=openid&state=abc123
Enter fullscreen mode Exit fullscreen mode

You should get an error response, not a login page. If you get the login page, any code issued for that client can be redeemed by whoever holds it.

What IdentitySuite does. The settings files that ship with the product set RequireProofKeyForCodeExchange to true in the Development, Staging and Production templates, and the admin UI's single-page app, web and native client templates also add the PKCE requirement to the client itself. One caveat: the default of that option in the code is false. The protection comes from the settings file, so if you start from your own file rather than the shipped one, check the value. OpenIddict itself does not enforce PKCE unless you tell it to, either globally or per client.

3. Tokens that live too long and cannot be revoked

A bearer token works for whoever holds it. Its lifetime is the window an attacker has after a leak, and a self-contained JWT cannot be recalled before it expires. Refresh tokens matter even more: a refresh token that lasts weeks and can be reused indefinitely is a long-lived credential sitting on a device.

What the attack looks like. A token ends up in a log, a crash report or a browser profile that is later copied. With a 24-hour access token the attacker has a day; with a refresh token that never rotates, they can keep minting new access tokens until it expires. RFC 9700 points out that refresh tokens exist so the authorization server can issue access tokens with a short lifetime, and requires refresh tokens for public clients to be either sender-constrained or rotated.

How to check. If you use JWT access tokens, decode one and subtract iat from exp; then ask what you would do if that token leaked right now. Use a refresh token twice: after rotation the second use should fail, allowing for a short grace period that exists for concurrent requests. Finally revoke a token and call your API with it. If the call still succeeds, revocation is not doing what you think it does.

What IdentitySuite does. The shipped settings use an access token lifetime of 30 minutes, an identity token lifetime of 30 minutes, an authorization code lifetime of 5 minutes and a refresh token lifetime of 14 days. Access and refresh tokens are reference tokens: the client holds an opaque handle and the token itself stays in the database, so revoking one is a change to a database record. Access token encryption is on. OpenIddict rotates refresh tokens by default — each use redeems the old one and issues a new one — and IdentitySuite does not turn that off.

💡 What reference tokens cost

An API that receives an opaque token cannot validate it locally; it asks the authorization server through introspection, which is a database lookup per request. That is the price of being able to revoke instantly, and the article on protecting a Web API shows how the validation side looks. Thirty minutes is a default, not a recommendation: for a sensitive API, lower it. For background on the token formats themselves, see What are JWT tokens.

4. CORS that allows every origin

Cross-origin resource sharing tells a browser which other sites' scripts may read responses from your server. A wildcard origin is not an exploit on its own — browsers will not combine it with credentialed requests — but it widens what any web page can read from your server on behalf of a visitor. The dangerous variant is a server that echoes back whatever Origin it receives and allows credentials: any site a signed-in user visits can then read authenticated responses.

What it means for an identity server. Its discovery document and key set are public by design, and single-page apps legitimately call the token endpoint from the browser. The more realistic damage is the habit of copying a permissive policy onto the APIs and admin endpoints that sit next to it.

How to check. Send a request with an origin that should not be allowed and look at the response headers:

curl -si -H "Origin: https://evil.example" \
  https://id.example.com/.well-known/openid-configuration | grep -i "access-control"
Enter fullscreen mode Exit fullscreen mode

Repeat it for the token endpoint and for any endpoint that uses cookies. You are looking at Access-Control-Allow-Origin and Access-Control-Allow-Credentials.

What IdentitySuite does. This is the one default I would not call locked down. The Cors section of the shipped settings has an empty AllowedOrigins list, and when that list is empty the server allows any origin. Credentials are not allowed by default, so the combination that makes a wildcard dangerous is absent. In production you should still list the origins you actually use:

"Cors": {
  "AllowedOrigins": [ "https://app.example.com" ],
  "AllowCredentials": false
}
Enter fullscreen mode Exit fullscreen mode

5. Secrets where anyone can read them

OAuth divides clients into two kinds. A confidential client can keep a secret: a server-rendered web app, a backend service. A public client cannot: a single-page app, a mobile or desktop app. Anything you ship to a user's device is readable by that user, so a client secret inside a JavaScript bundle or an app package is not a secret.

What the attack looks like. Someone opens the bundle, finds the client_id and client_secret, and can now authenticate to your server as that client — wherever the server relies on the secret as proof of identity. The fix is not to hide the secret better. Public clients should not have one; they rely on PKCE instead.

How to check. Search your front-end build output, your mobile app package and your repository history for client_secret and for the secret values themselves. Then list the clients on your server and compare each one's type with where it actually runs.

What IdentitySuite does. The admin UI distinguishes public from confidential clients. The single-page app and native app templates create public clients with no secret and PKCE required; the machine-to-machine template creates a confidential one. The form cannot see your JavaScript bundle, though: if a confidential client's secret gets pasted into front-end code, nothing on the server will notice.

6. The implicit flow and the password grant still switched on

The implicit flow returns tokens directly in the redirect, in the URL fragment. It was invented when browsers could not make cross-origin requests to a token endpoint; they can now, and PKCE exists. The resource owner password credentials grant hands the user's actual password to the client.

What the attack looks like. Tokens in a URL end up in browser history, referrer headers, logs and extensions, and nothing binds them to the client that received them. RFC 9700 says clients should not use the implicit grant or any response type that issues access tokens in the authorization response, and that the password grant must not be used at all.

How to check. Fetch the discovery document and read two lists:

curl -s https://id.example.com/.well-known/openid-configuration
# look at: response_types_supported, grant_types_supported
Enter fullscreen mode Exit fullscreen mode

If token, id_token token or password appears there, at least one client can be enabled to use them.

What IdentitySuite does. The shipped settings set AllowImplicitFlow and AllowPasswordFlow to false. There are two layers: the server-level switch decides whether a flow exists at all, and a per-client permission decides which clients may use it — OpenIddict rejects a request for a feature the client has not been granted. The admin UI still offers a legacy single-page app template that configures the implicit flow, for applications that cannot move yet; it needs the server-level switch on as well.

The six checks on one page

Setting Quick check IdentitySuite shipped settings
Redirect URIs Request altered variants of a registered URI Full URIs only, exact matching by OpenIddict. Review leftovers.
PKCE Authorization request without code_challenge Required server-wide in all three templates
Token lifetimes Subtract iat from exp; reuse a refresh token; revoke and retry 30 min access, 14 days refresh, reference tokens, rotation on
CORS Request with a foreign Origin header Not restricted: empty list means any origin, credentials off. Set it.
Client secrets Search bundles, packages and repository history SPA and native templates are public, no secret
Implicit / password Read the discovery document Both off at server level

Defaults help, but they are not a review

A settings file is a starting point. Teams copy it between environments, edit it under pressure and forget what changed, so run these checks again after any change to the authorization server, not just once at launch. The six here are the ones I would look at first; they are not a complete list.

Everything above assumes the server you run is still receiving fixes. If you are on IdentityServer4, that assumption no longer holds, and this comparison of your options is the place to start. If you want the background on the protocol behind all of this, the OAuth 2.0 explainer covers the flow step by step.

Want to run these checks against a server of your own? Get started with IdentitySuite for free.

Top comments (0)