DEV Community

rinat kozin
rinat kozin

Posted on Originally published at redbase.app

Your access token was stolen. Now what: three layers of defence in your own OpenID provider

redb.Identity

A bearer token works for whoever holds it. Three layers in redb.Identity: BFF, key-bound tokens via DPoP, fast revocation. Plus what a stolen database yields.

An access token is a bearer token by default, and that phrase means exactly what it says: whoever holds it, is you. The server cannot tell the real owner from someone who pulled the token out of a browser that was not theirs. Until it expires, that token works from any machine, in any country, and in your logs it looks like perfectly ordinary traffic.

Then comes the unpleasant part. Access-token lifetimes in real configurations run from fifteen minutes to an hour. For all of that time the attacker has full access, and nobody gets a signal about it.

We built redb.Identity, our own OAuth 2.1 / OpenID Connect provider on .NET, and "what if the token gets stolen" was not a question we could answer with a slogan. What came out of it is three layers: do not let it be stolen, make the stolen thing worthless, kill it fast. Below is each one, with code and with the limits of where it applies.

After that, a nastier scenario: what is left for an attacker who walked off not with a token, but with the entire database.

Where token theft actually comes from

Discussions about stolen tokens tend to collapse into XSS. That is correct but incomplete. If a token lives in the browser, all of the following reach it:

  • XSS in your own application, the classic;
  • XSS through a dependency. A compromised npm package gets exactly the same access to the page as the code you wrote. You can build a flawless application and still hand out tokens, because somewhere in the transitive dependency tree one package changed hands. Auditing your own code does not close this one;
  • browser extensions. The user installs them, and they have access to the DOM and to the page's network calls;
  • localStorage. Readable by any JavaScript on the same origin, and it survives reloads and tab closes. Storing a token there is a discipline of its own, and enough has been written about it.

What all four share: the attacker walks away with the token itself. After that they need neither the victim's browser, nor their network, nor their session.

Layer one: do not let it be stolen

How most identity servers build their admin console

Take any mature identity server and look at how its admin console is built. Keycloak ships a React application. WSO2 Identity Server ships React too, for both Console and My Account. Both operate as public OAuth clients: the browser runs code+PKCE, receives an access token, and keeps it.

This is not sloppiness, it is the default that settled in for SPAs years ago. It has a price. An access token with administrator rights over the identity server sits in page memory that JavaScript can read. Any of the four vectors above, landing on that page, means leaking the admin token of the very server that grants access to every other system in the company.

Worth noting that the IETF's current BCP for browser apps, OAuth 2.0 for Browser-Based Applications, recommends the BFF pattern and describes browser-held tokens as something to move away from. The industry has voted; mature products are simply carrying compatibility with what was written earlier.

What a BFF is, briefly

Backend-for-Frontend: a server-side application sits between the browser and the API. It runs the OIDC exchange over the back channel, server to server, and keeps the tokens. The browser gets an HttpOnly session cookie. There is no token in the browser at all, and JavaScript cannot reach it by construction rather than by agreement.

How ours is built

redb.Identity.Web is the reference admin console and account portal. It runs on Blazor Server plus cookie authentication:

builder.Services.AddRazorComponents().AddInteractiveServerComponents();
// ...
.AddCookie(options =>
{
    options.Cookie.Name = "identity.web.session";
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = true;
Enter fullscreen mode Exit fullscreen mode

After login the tokens go onto the authentication ticket, not out to the client:

AuthenticationTokenExtensions.StoreTokens(props, BuildTokens(result));
Enter fullscreen mode Exit fullscreen mode

When server-side code needs an access token to call Identity, it takes it from the current context rather than from the browser's request:

public async Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
{
    var ctx = _accessor.HttpContext;
    if (ctx?.User?.Identity is not ClaimsIdentity { IsAuthenticated: true })
        return null;

    return await ctx.GetTokenAsync(CookieAuthenticationDefaults.AuthenticationScheme, "access_token");
}
Enter fullscreen mode Exit fullscreen mode

Intermediate state works the same way. The MFA challenge, the consent challenge and the impersonation state are separate HttpOnly cookies under DataProtection, not fields in localStorage and not query parameters:

SameSite = SameSiteMode.Lax,
HttpOnly = true,
Enter fullscreen mode Exit fullscreen mode

Blazor Server adds a level that a classic BFF does not have. Markup renders on the server and only a diff travels to the browser over SignalR. There is physically no JavaScript bundle holding application state. There is nothing to attack, not because we hid it well, but because nothing is there.

Where BFF does not help

With a BFF, an attacker who achieves JavaScript execution on the page can still call the API as the victim: the browser attaches the cookie automatically. What they cannot do is take the token away and work with it later.

Stolen token (SPA) XSS behind a BFF
Where the attacker operates from their own machine, anywhere only from the victim's browser
For how long the token's whole lifetime while the page stays open
Survives closing the tab yes no
Can it be replayed later yes, the token is gone no, there is nothing to take
Odds of catching it by anomaly higher: foreign IP, foreign user-agent, foreign geography lower: IP, user-agent and working hours all match the victim

That last row runs against the BFF, and it deserves to be said out loud because it breaks the convenient picture. A stolen token is used from someone else's machine, which is precisely the signal impossible-travel detection exists for. XSS inside the victim's browser arrives from their own address, with their user-agent, during their working hours, and is behaviourally almost indistinguishable from the person. So on detectability the BFF loses, and the symmetric "different IP" argument does not work in our favour.

A caveat to the caveat: you can only cash in that advantage if you actually have anomaly detection. We do not have it yet, which gets its own section further down.

So the BFF wins four rows out of five and clearly loses the fifth. It converts a quiet hour of access from another country into an active session inside the victim's browser while they are looking at the screen. A smaller blast radius, not a cure, and certainly not a substitute for monitoring.

Take a cookie, remember CSRF

This consequence is easy to forget in the relief of having no token around. A browser never attaches a bearer header on its own, but it does attach a cookie to any request aimed at your domain, whatever page that request came from. In other words, moving to a BFF does not remove the risk, it changes its shape: token theft is replaced by cross-site request forgery.

So the BFF runs both lines of defence. Session cookies and every piece of intermediate state carry SameSite=Lax, HttpOnly and Secure. On top of that the pipeline enables app.UseAntiforgery(), and every form that changes anything carries <AntiforgeryToken />: login, MFA challenge, consent, email change, password reset, device verification, profile edit. Twelve forms, none without a token.

Both lines are needed together. SameSite=Lax blocks cross-site POSTs, but it is not a full replacement for an antiforgery token: it does not save you from an attack launched from your own subdomain, and it stops helping the moment some flow needs SameSite=None. The rule is simple. You took a cookie, so bring CSRF protection, no matter how good the other flags are.

What about mobile clients

The BFF is a browser pattern, and native applications do not fit into it. Telling a mobile app to embed a web view for the sake of a BFF would in fact violate RFC 8252 directly: it requires the system browser for authorisation, not a component living inside the app.

Native gets a different combination:

  • the system browser for authorisation per RFC 8252, plus PKCE, which OAuth 2.1 makes mandatory;
  • the operating system key store for tokens, Keychain and Keystore, rather than a file in the app sandbox;
  • layer two, meaning DPoP, and here it carries the main weight. The private key is generated inside the device's secure storage and never leaves it, so a token lifted out of the app is useless without it.

The precise way to put it: BFF solves the problem for the browser, DPoP solves it for everything else. A browser has somewhere to hide the token server-side, a mobile app does not, so what gets protected there is not storage but use.

Layer two: make the theft worthless

This is DPoP, RFC 9449, Demonstrating Proof-of-Possession. The idea is simple and radical: stop issuing bearer tokens.

The client generates a key pair and keeps the private half. When asking for a token it presents the public half, and the server stamps that key's thumbprint into the issued token. From then on every request to a resource carries a separate short JWT proof, signed with the private key and bound to a specific method and URL:

POST /api/orders HTTP/1.1
Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
Enter fullscreen mode Exit fullscreen mode

Steal the token without the private key and you have a useless string. The server will demand a proof, and there is nothing to sign it with.

What is implemented on our side:

  • binding at issuance. The key thumbprint per RFC 7638 (SHA-256 over the JWK, base64url) goes into the cnf.jkt claim per RFC 7800. The token now knows which key it belongs to;
  • DPoP-Nonce per §8, built on HMAC-signed stateless nonces. The server can demand a fresh proof without holding per-client state;
  • a store of consumed jti values keyed by jkt. The same proof will not pass twice, even if it was intercepted whole;
  • asymmetric algorithms only, from an allow-list, as §4.2 requires. A symmetric "proof" proves nothing, because both sides know the key;
  • a separate package, redb.Identity.Resource.Dpop. This is the validator for your own resource APIs. Proofs must be checked somewhere other than the provider itself, otherwise the protection ends at the identity server's boundary while the data lives further along.

A note on comparisons. DPoP now exists in Keycloak and in WSO2 as well. It arrived there later, and in Keycloak it sat in preview status for a long while, but "they do not have it" would be wrong. The right question is different: check your specific version for whether it is on and what status it carries, because a preview feature in production is a separate conversation with your own security team.

Layer three: kill it fast

Even perfect binding does not remove the need to revoke. Somebody left the company, a device was lost, an incident was confirmed. From there only one question matters: how long until the revocation actually reaches every system.

Corporate requirement documents love this point, and it usually appears in them as an open question rather than as a decision.

What exists:

  • token revocation per RFC 7009, idempotent: a repeated revocation returns 200 as §2.1 requires, and does not turn into a source of script failures;
  • refresh-token rotation. A used refresh token is revoked, and presenting it again means somebody is working from a copy;
  • session idle timeout. Every session carries its last-activity time, refreshed on real actions: a refresh exchange, cookie validation, a userinfo call. A session that has idled past the limit is killed automatically;
  • backchannel logout in two modes. This is the interesting one, so in more detail.

Classic OIDC Backchannel Logout is push: the provider knocks on every registered application and reports that a session has ended. That works right up to the first network partition or crashed replica. An application the knock never reached goes on believing the session is alive.

So next to push we run a pull feed of revoked session identifiers, with a cursor:

POST /api/v1/identity/revoked-sids/add
GET  /api/v1/identity/revoked-sids/since?cursor=...
Enter fullscreen mode Exit fullscreen mode

An application that came back from a crash, or lost connectivity for ten minutes, simply asks what has been revoked since its cursor and catches up. No revocation is lost because a node happened to be unreachable when the broadcast went out.

And a detail that ties layer one to layer three. The BFF checks that same list on every request while validating the session cookie:

options.Events.OnValidatePrincipal = async ctx =>
{
    var sid = ctx.Principal?.FindFirst("sid")?.Value;
    var sub = ctx.Principal?.FindFirst("sub")?.Value;
    // if sid/sub is in the cluster-wide revoked list, drop the cookie
Enter fullscreen mode Exit fullscreen mode

So "sign out everywhere" kills not only tokens but the live UI session too, across every replica rather than just the one that happened to receive the logout call.

The routine around it

Three layers are the visible part. Below them sits work you only notice if you go looking. The parts I consider worth listing:

Password history. Configurable depth, reuse of a previous password is rejected, hashed with the same algorithm as the current one.

TOTP with replay protection. The problem with a stock TOTP implementation: a code is valid across a tolerance window, and the same code can be presented twice if you are quick. We store the last accepted step and reject a code from a step already used, even when it formally falls inside the window:

if (props.LastTotpStep.HasValue && step <= props.LastTotpStep.Value)
    return false;
Enter fullscreen mode Exit fullscreen mode

On top of that the MFA row is taken under a lock before verification, so parallel attempts by one user cannot diverge on the read-then-write.

SMS and email one-time codes live server-side, not in state. The code itself is stored hashed (SHA-256) and marked consumed under LockForUpdate, while the encrypted challenge state carries only a reference to it. The client never holds anything the code could be reconstructed from.

Recovery codes are genuinely single-use. They are marked consumed inside the same transaction that creates the session. Not "mark first, create after", but atomically, otherwise the gap between the two operations becomes a window.

Constant-time secret comparison. CryptographicOperations.FixedTimeEquals appears in sixteen files: password-reset tokens, email verification, email change, server-side OTPs, the bootstrap secret. A timing attack on string comparison is not exotic, it is what automated scanners look for.

Rate limiting on three levels: per IP, per client_id through a token bucket, and a separate ceiling on failed attempts per (IP + user) pair, logged to a dedicated security channel.

Order of proxy-header processing. X-Forwarded-For is sanitised before the rate limiter and the lockout counter ever see it. Do it the other way around and an attacker forges the header to bypass both, and can also get somebody else's IP locked out. It only applies when the socket peer is on the trusted-proxy allow-list, otherwise the header is ignored entirely.

The idempotency cache sits after authorisation, not before. Otherwise a revoked token unlocks a cached response produced while it was still alive.

The __Host- prefix is emitted only when Secure=true. RFC 6265bis §4.1.3.2 requires it, and it is easy to get wrong: set the prefix, forget the flag, and end up with a cookie the browser silently drops while you hunt for the bug in your code.

SSRF protection on outbound fetches. There are exactly two situations where a client can make our server follow a link it supplied: resolving jwks_uri and fetching a request object by request_uri. Both go through one guard that refuses loopback, RFC 1918 private ranges, link-local including the 169.254.169.254 cloud metadata address, and RFC 6598 CGNAT space. Private targets open only behind an explicit flag, for single-host test rigs.

Refusal of alg:none. Our JAR implementation (RFC 9101) accepts a signed request object, verifies the signature against the client's keys and takes the parameters from inside the JWT. An unsigned object is rejected always, whatever the settings say: it throws away precisely the integrity guarantee JAR exists to provide. FAPI 2.0 forbids it outright.

One hundred and nine typed audit events across seven categories, into a flat table and optionally multicast to Kafka, Elasticsearch or RabbitMQ. Passwords and secrets never reach the audit: a client-secret rotation records the fact, not the value.

What if the whole database is stolen

So far this has been about one token. Now the worst case: the attacker has a full dump.

The threat model changes completely. No rate limits, no audit, no revocation. The attacker works offline, on their own hardware, for as long as they like, and you will not find out. The only thing protecting the data at that moment is the shape it is stored in.

Passwords

Hashed with Argon2id, using the OWASP 2023 parameters: 64 MiB of memory, 3 iterations, 4 lanes, a 16-byte per-user salt and a 32-byte hash.

Three properties that buys. The password is hashed rather than encrypted, so no key exists anywhere that could decrypt everything at once. Each user gets their own salt, so rainbow tables are useless and you cannot crack everyone whose password is qwerty123 in a single pass. The computation is deliberately slow, so instead of billions of attempts per second the attacker gets a handful.

Then memory, which is the real difference from bcrypt. Bcrypt needs roughly 4 KB per computation. On a GPU or a purpose-built ASIC that means tens of thousands of parallel instances, and the gap between a defender on an ordinary CPU and an attacker on a farm becomes enormous. Argon2id at 64 MiB breaks that arithmetic: 24 GB of video memory holds on the order of 380 lanes instead of tens of thousands. This is why Argon2id won the Password Hashing Competition and why OWASP lists it first.

Bcrypt has not gone anywhere, and that is deliberate. The redb.Core storage layer historically hashed passwords with bcrypt at work factor 12, and existing deployments have exactly that in their tables. So Identity registers a dispatcher that writes new hashes with Argon2id while still verifying old bcrypt and even ancient salted SHA-256:

builder.Services.TryAddSingleton<IPasswordHasher>(sp =>
{
    var argon2 = new Argon2idPasswordHasher(...);   // 64 MiB, t=3, p=4
    var bcrypt = new BcryptPasswordHasher(workFactor: opts.Bcrypt.WorkFactor);

    if (opts.Algorithm == PasswordHashAlgorithm.Bcrypt)
        return bcrypt;

    return new MultiFormatPasswordHasher(argon2, bcrypt);
});
Enter fullscreen mode Exit fullscreen mode

Upgrade-on-login is wired too: on a successful sign-in the hasher is asked whether the stored format is stale, and if so the password is quietly rehashed to Argon2id and saved. The database migrates itself as people log in. No forced password reset for everyone at once, the kind users hate and support desks survive like a natural disaster.

Everything else in the database

Passwords are one row among many. The full inventory:

What is stored In what form
User passwords Argon2id, legacy bcrypt until first sign-in
Password history same hasher as the current password
OAuth client secrets bcrypt hash, not recoverable
TOTP secrets encrypted
Recovery codes PBKDF2-HMAC-SHA256, per-code salt, plus a pepper
SMS and email one-time codes SHA-256, single-use, consumed under a lock
Password-reset, verification and email-change tokens hashed, compared in constant time
Private signing keys encrypted through DataProtection
DataProtection key ring encrypted at rest

Two of those deserve their own paragraphs.

The pepper does not live in the database

Recovery codes are protected by more than a salt. The formula also takes a pepper, a separate secret supplied through an environment variable and never stored in a table.

That is exactly the difference between a salt and a pepper. A salt sits next to the hash and defeats rainbow tables, but it does nothing against brute force. A pepper does not sit next to it. With only a dump in hand there is nothing to brute-force recovery codes with, because the formula uses a value the dump does not contain.

The key chain ends at a root that is not in the dump

This is the part that matters most when an identity server's database is stolen. The worst outcome is not "the passwords were cracked", it is "the private signing key was obtained". With that, a token can be forged for anyone, administrators included, and no amount of password hardening helps.

Private signing keys are stored encrypted, in an EncryptedPem field protected by IDataProtector.Protect under a dedicated purpose. The obvious next question is what protects the DataProtection key ring itself, given that it also lives in the database. Otherwise you get a circle where the lock and the key sit in the same box.

There is no circle. The key ring is encrypted at rest by one of three means: an X.509 certificate, a 32-byte AES-GCM master key, or your own hook into a KMS or Vault. And this is not a recommendation in the documentation, it is a startup condition:

if (dp.RequireAtRestEncryption && !options.AllowEphemeralKeys)
{
    throw new InvalidOperationException(
        "DataProtection key-ring is unprotected at rest. Configure ONE of: ...");
}
Enter fullscreen mode Exit fullscreen mode

RequireAtRestEncryption defaults to true. The server refuses to start in production if the key ring is unprotected. Turning the check off takes an explicit setting, named clearly enough that you will not do it by accident.

The net result: a dump yields encrypted signing keys, an encrypted key ring, and no root key. Ciphertext all the way down.

What a stolen database does achieve

Without this part the paragraphs above would be lying by omission.

Encryption does not undo a breach. A dump readily gives up personal data (names, emails, phone numbers, departments, managers, employee numbers), organisational structure (groups, roles, who has access to what), session metadata (IP addresses, devices, activity times, meaning who works from where) and the entire audit trail.

Under most data-protection regimes that is a personal-data breach with every obligation attached, regardless of the fact that not a single password was cracked. Saying "everything is encrypted" in that situation closes a conversation that must not be closed.

And a second, operational caveat. The whole chain rests on the root key living somewhere other than the database. If your backup is taken together with the configuration file, or with the container's environment variables, the protection collapses to zero: the attacker has the box and the key. Database backups and secret storage belong in different perimeters with different access rights. RequireAtRestEncryption protects against a stolen dump, not against a stolen backup, and those are different things.

What is actually exposed

A separate question, often more important than any cryptographic detail: which part of the system is reachable from the internet at all.

In most identity servers the admin console lives in the same process and on the same port as the protocol endpoints, under a path like /admin. It can be isolated, there are host settings and a reverse proxy for that, but the separation is by URL and by configuration. One mistake in the proxy rules or a regression in the hostname settings, and the management plane is outside.

Ours separates by process and by port.

The core is not networked at all. Every redb.Identity.Core endpoint lives on a direct-vm:// route, an in-process transport. This is not "listening on localhost": there is no network path to the core in principle, except through a facade you explicitly stood up.

Inside the HTTP facade the planes sit on different ports:

"Http": {
  "PublicPort": 5002,
  "ManagementPort": null
}
Enter fullscreen mode Exit fullscreen mode

PublicPort carries only /connect/* and /.well-known/*. ManagementPort carries /api/v1/identity/* and /scim/*, and the setting's own comment states in plain words that production wants a separate firewalled port. SCIM is additionally gated by a flag and can stay down entirely.

The emergency bootstrap endpoint is separated further. POST /internal/bootstrap-admin lives on the management port but outside the /api/v1/identity/ base path, so a firewall rule can close it independently of everything else. It has no bearer authentication by design; protection is a header secret compared in constant time. CORS on it is disabled deliberately, because it is a back-channel operator tool and is never invoked from a browser.

The UI is a separate application. redb.Identity.Web is a standalone ASP.NET host that talks to Identity over a server-side HTTP client. A different machine, a different network segment, or not deployed at all: Identity does not stop working either way. It ships as source rather than as a package precisely because it is a reference you are meant to edit.

The point of the whole arrangement is the cost of a mistake. Exposing the management plane here requires deliberately publishing the management port or deploying the Web app into a DMZ. That is hard to do by accident.

An external arbiter

Everything above can be asserted about any server, and usually is. The trouble is that your own tests find exactly the bugs you already knew to look for.

We ran the server through the official OpenID Foundation conformance suite, the same one the Foundation certifies providers with. Config OP passes with no failures. Basic OP is thirty-five modules, and FAILED among them is zero.

Far more interesting is what it found in us. Scope-derived claims (phone, address) were landing in the id_token, and an id_token is forwarded to third parties and written to logs as proof of sign-in. So a user's phone number travelled considerably further than the client ever asked for. That is a PII leak, and none of our own tests caught it, because we did not know it was a bug. There is a full write-up of that run, including suite setup and the complete list of findings: running our OpenID server through the official suite.

Two modules in the report show as SKIPPED, and both concern unsigned request objects with alg:none. They are skipped because the server refuses to advertise the unsafe mode. In a conformance report you read the reason, not the colour of the row.

We do not carry the OpenID Certified™ mark. That is a trademark, granted by the Foundation through a separate paid submission. What is claimed is only what is true: the server is run against the official suite, and these are the results.

What is not there yet

The section without which this would be an advertisement.

Risk-based authentication. The inputs are already stored: session IP, user-agent, a readable device label, sign-in history in the audit trail, failed-attempt counters. What is missing is the rules engine and the escalation policy on top of them.

You can see the gap most precisely in acr. The server reports the level reached: acr of 1 for single-factor and 2 for verified multi-factor, plus amr with the concrete method (pwd, otp, mfa, hwk). What it does not act on is an incoming acr_values as a demand to raise the level. The parameter is advertised in discovery, but you cannot use it to make the server ask for a second factor. That is exactly what step-up needs, along with RFC 9470.

Trusted devices as a first-class entity. The device is recorded on the session, but there is no record saying "trust this device until such a date".

SAML 2.0. Not implemented in either direction. The decision is deliberate and written down: OIDC federation covers modern integrations, while full SAML means XML signatures, metadata exchange, three binding types and Single Logout with all its quirks. The conditions under which we would take it on are written down too, and the first is a real customer with an enterprise IdP that will not speak OIDC.

From the RFCs: Rich Authorization Requests (9396), the JWT profile for access tokens (9068, the typ=at+jwt header), the iss parameter in the authorisation response (9207), step-up (9470). Plus CIBA and OIDC Federation 1.0.

On mTLS specifically, because this one is easy to misrepresent. Transport mTLS exists: the gRPC facade can require a client certificate and pin it by thumbprint against an allow-list, and for the management port that is the recommended production setting. What does not exist is RFC 8705, which is a different thing: mTLS as a way of authenticating an OAuth client at the token endpoint, plus binding the issued token to the certificate through cnf.x5t#S256. The first protects the channel, the second would make the token as non-transferable as DPoP already makes it. We went the DPoP route.

FAPI 2.0 is not claimed as a profile. Individual bricks from it are in place: mandatory PAR can be switched on per client, alg:none is always rejected, and only asymmetric algorithms are allowed for request objects. But a profile is not a set of checkboxes, it is passing the corresponding conformance plan, and we have not run it. If your security team requires FAPI, treat that as a separate body of work, and better to learn it now.

What to take away

Three thoughts, if any survive the scroll.

First. A token in the browser is a choice, not a necessity. The BFF is the recommended pattern for browser applications, and moving to it changes not the probability of compromise but its radius: instead of a quiet hour of access from another country you get activity inside the victim's browser, bounded by an open tab. The price of the trade is that such activity is harder to catch by anomaly, which is worth holding in mind rather than treating the BFF as a free improvement.

Second. A bearer token is also a choice. DPoP turns a stolen string into a useless one, it switches on at the provider rather than by rewriting every client at once, and it works where a BFF cannot: mobile and desktop native, service-to-service calls. Check whether your server has it, and what status it carries.

Third. Revocation speed is an architecture question, not a token-lifetime question. Push notifications to applications break at the first network partition. A pull feed with a cursor survives partitions and crashed nodes, and costs very little.

Fourth, on database theft. Check two things about your own server, both of which take five minutes. One: what hashes your passwords and whether upgrade-on-login exists, because without it the database never migrates to a modern algorithm. Two, and this matters more: where the key lives that encrypts your private signing keys. If it is in the same database, the encryption is decorative, and the worst case of a stolen dump is wide open.

redb.Identity is Apache 2.0, runs on PostgreSQL, MS SQL and SQLite with no code changes, and can be hosted as an in-process module if you do not want a network between your services. Have a look on GitHub.

If this was useful — a ⭐ on GitHub helps others find it.

More of my writing: redbase.app/articles, and on dev.to.

Top comments (0)