Building DynamoDB Stores for OpenIddict
In my previous post, I walked through setting up an OAuth2/OIDC server with OpenIddict 6, AWS Lambda, API Gateway, and Aurora Serverless v2. That worked well when SQL storage was the right fit, but I also wanted a DynamoDB backed option for workloads that are already built around AWS serverless components and need predictable request latency without managing database connections.
OpenIddict already ships with Entity Framework Core stores, and the ecosystem has MongoDB support as well. What was missing for my use case was an official DynamoDB store. There is a community package, ganhammar/OpenIddict.AmazonDynamoDB, but I did not want to copy its storage model directly.
The main concerns were DynamoDB specific:
- A single table design makes all OpenIddict entity types share the same TTL, capacity, and index budget.
- The reference implementation uses 8 GSIs, which is already close to being uncomfortable when the data model grows.
- Some OAuth lookups, such as
FindByClientIdAsyncandFindByReferenceIdAsync, need correctness more than index convenience. A GSI read is eventually consistent.
So I started building OpenIddict.DynamoDb with a simple goal: keep the OpenIddict store API familiar, but model DynamoDB access patterns explicitly instead of treating DynamoDB as a document database with secondary indexes.
This post focuses on the storage design and the implementation tradeoffs. It is not an introduction to OpenIddict itself. If you are starting from scratch, the previous post is a better place to begin.
The problem with treating OAuth storage as generic CRUD
At first glance OpenIddict stores look like CRUD repositories. There are applications, authorizations, scopes, and tokens. Each store has CreateAsync, UpdateAsync, DeleteAsync, lookup methods, and a few query methods.
The hot path is much smaller than the API surface suggests.
During OAuth flows, OpenIddict mostly needs to:
- Find an application by
ClientId. - Validate redirect URI and permissions for that application.
- Create authorization and token records.
- Find a token by reference id for reference token validation.
- Check whether a token and its parent authorization are still valid.
Methods such as ListAsync, CountAsync, and PruneAsync exist, but they are maintenance and admin operations. A good DynamoDB model should tune the paths that run during authentication, not the paths that run once per background job.
The design I landed on has four tables:
| Table | Entity | Main access pattern | TTL |
|---|---|---|---|
OpenIddictApplications | Applications and lookup items | ClientId, id, redirect lookup | No |
OpenIddictAuthorizations | Authorizations | id, subject, application, status | Optional |
OpenIddictScopes | Scopes and lookup items | name, id | No |
OpenIddictTokens | Tokens and reference lookup items | id, reference id, subject, authorization | Yes |
This is less clever than a single table design. It is also easier to operate.
Design decision 1: multi-table storage over single-table storage
Single table design is often a good default for DynamoDB when the domain has a shared lifecycle and a small number of well known access patterns. OpenIddict entities do not have the same lifecycle.
Tokens are short lived and write heavy. Applications and scopes are configuration data. Authorizations sit somewhere in between, depending on whether the server uses long lived consent records. Putting all of these into one table creates a few practical issues.
| Concern | Single table | Four tables |
|---|---|---|
| TTL | One TTL attribute name per table; not every item needs to carry it, but all entity types share the same attribute name | Tokens can expire automatically without touching applications or scopes |
| Throughput | Token writes compete with configuration reads | Token capacity can be tuned separately |
| GSI budget | All entity types share the same 20 GSI limit | Each table gets its own 20 GSI limit |
| Operations | One table is simple to create | Separate tables are easier to inspect and alarm on |
The GSI budget was a major reason. Tokens alone need queries by subject, application, authorization, status, type, and reference id. Sharing the same index budget with applications and scopes was not worth it.
Single table design is not wrong here. It is just not the tradeoff I wanted for a library that should be predictable for many OpenIddict setups.
Design decision 2: materialized lookup items over GSI only lookups
DynamoDB GSIs are eventually consistent. For many systems this is fine. For OAuth storage, a few lookups are correctness sensitive.
For example, FindByClientIdAsync is called when OpenIddict validates a client. If an application is created or rotated and the next request reads stale data from a GSI, the server can reject a valid client or accept old configuration. The same concern applies to reference tokens. FindByReferenceIdAsync is on the validation path for opaque tokens.
Instead of relying only on GSIs, the store writes lookup items into the base table.
Application metadata: PK = "APP#<id>", SK = "#META"
ClientId lookup: PK = "CLIENT#<clientId>", SK = "#LOOKUP"
Display name lookup: PK = "NAME#<name>", SK = "#LOOKUP"
Token metadata: PK = "TOKEN#<id>", SK = "#META"
Reference lookup: PK = "REF#<referenceId>", SK = "#LOOKUP"
A lookup item is small. It usually contains the target entity id and enough metadata to detect conflicts. The full entity stays in the metadata item.
That gives us a base table GetItem for the lookup key. DynamoDB base table reads default to eventual consistency, so the store explicitly sets ConsistentRead = true on these correctness-sensitive lookups. This consumes twice the RCU per read but guarantees the latest write is visible. There is no extra index to maintain for that read path, and we avoid timing issues caused by GSI propagation lag.
The read flow for reference token validation looks like this:
The extra GetItem is deliberate. It buys correctness with a predictable request pattern. Note that not every token has a parent authorization — the store only reads the authorization record when the token references one.
Design decision 3: transactional writes for related items
Once lookup items are part of the base table model, writes become multi item operations. A token with a reference id is not just one item anymore. It is at least two items:
TOKEN#<id>metadata item.REF#<referenceId>lookup item.
Applications are similar. Creating an application can write the metadata item, the CLIENT#<clientId> lookup item, and redirect URI lookup items.
Those items must be updated atomically. If the token item is written but the reference lookup item fails, reference token validation breaks. If the lookup item is written but metadata fails, the client id becomes reserved by a dangling item.
DynamoDB TransactWriteItems is the right primitive for these cases.
var request = new TransactWriteItemsRequest
{
ClientRequestToken = idempotencyKey,
TransactItems =
[
new TransactWriteItem
{
Put = new Put
{
TableName = options.TokensTableName,
Item = tokenItem,
ConditionExpression = "attribute_not_exists(PK)"
}
},
new TransactWriteItem
{
Put = new Put
{
TableName = options.TokensTableName,
Item = referenceLookupItem,
ConditionExpression = "attribute_not_exists(PK)"
}
}
]
};
await dynamoDb.TransactWriteItemsAsync(request, cancellationToken);
ClientRequestToken makes retries safer. If a Lambda invocation times out after DynamoDB accepts the transaction, the retry can send the same token and DynamoDB can treat it as the same logical write.
Do not split these writes into independent PutItem calls and hope cleanup code catches failures. OAuth state needs to be internally consistent at the moment the server returns a token response.
A few practical notes on the transaction above. TransactWriteItems supports up to 100 actions and 4 MB total size, with no two actions targeting the same item. The ClientRequestToken provides idempotency within a 10-minute window, but only if the retry sends the same transaction body. If your Lambda times out after DynamoDB commits and the retry generates a new token id, the idempotency token will not prevent a duplicate. Design your retry logic accordingly. Transactional writes also consume 2x the write capacity units compared to non-transactional writes.
Design decision 4: authorization status remains the source of truth
Revocation is a common place to accidentally design a fragile system.
A naive approach is to update every token when an authorization is revoked. That works for small users, but it gets risky when one authorization has thousands of tokens. A fanout update can fail after 4,000 out of 10,000 records. Now the system needs to know what changed and whether a retry is safe.
Instead, token validation checks the parent authorization at read time. Token status still exists, but authorization status is the source of truth for authorization wide decisions.
Fanout updates can still exist as background cleanup. They can reduce future reads and remove expired data, but they are not part of correctness. If cleanup stops halfway, token validation still returns the right answer.
This is especially useful with OpenIddict because pruning is already a background concern. PruneAsync can run from Quartz or another scheduler without affecting the correctness of the OAuth flow.
Design decision 5: write sharding for GSI partition keys
Queries by application id are useful for admin screens and cleanup, but token creation can be very write heavy. If all tokens for the same application land on the same GSI partition key, one busy client can create a hot partition on the index.
A tempting GSI key is:
PK = APP#<applicationId>#S#<hash(applicationId) % 10>
That does not help. The shard is stable for the application, so every token for the same application still goes to one partition key.
The token store shards the GSI partition key by token id instead:
GSI3 PK = APP#<applicationId>#S#<hash(tokenId) % 10>
GSI3 SK = STATUS#<status>#TYPE#<type>#TOKEN#<id>
The base table still uses TOKEN#<id> as its partition key, which is already well-distributed. The sharding here is specifically for the GSI that supports application-scoped queries. Now one application can spread GSI entries across 10 partition keys. The tradeoff is that FindByApplicationIdAsync becomes a scatter query across all shards.
// Simplified example. Production code must handle pagination,
// limits, ordering, cancellation, and throttling per shard.
var tasks = Enumerable.Range(0, 10).Select(shard => QueryApplicationShardAsync(
applicationId,
shard,
cancellationToken));
var pages = await Task.WhenAll(tasks);
return pages.SelectMany(page => page);
That is acceptable because application wide token queries are not on the token request hot path. They are admin and maintenance operations. We can spend extra reads there to protect the write path.
Design decision 6: composite sort keys and null sentinels
DynamoDB does not allow null values in key attributes. OpenIddict models have optional fields, but query keys still need stable values. The store uses _NULL_ as a sentinel for key segments.
Composite sort keys give us prefix queries without adding a GSI for every filter combination.
STATUS#<status>#TYPE#<type>#TOKEN#<id>
STATUS#valid#TYPE#access_token#TOKEN#01HZY...
STATUS#_NULL_#TYPE#_NULL_#TOKEN#01HZZ...
With this shape we can query all valid tokens, all valid access tokens, or a specific token ordering under a partition key.
The important part is to normalize the key segment in one place. The store should never have one path writing an empty string and another path writing _NULL_, because that creates invisible query bugs.
Tokens table schema highlight
The tokens table is the busiest table, so it is a good reference for the overall pattern.
Token metadata: PK = "TOKEN#<id>", SK = "#META"
Reference lookup: PK = "REF#<referenceId>", SK = "#LOOKUP"
GSI1: SubjectApp: PK = "SUBJAPP#<sub>#<app>", SK = "STATUS#<s>#TYPE#<t>#TOKEN#<id>"
GSI2: Subject: PK = "SUBJ#<subject>", SK = "TOKEN#<id>"
GSI3: App shard: PK = "APP#<app>#S#<shard>", SK = same as GSI1 SK
GSI4: Authz: PK = "AUTHZ#<authzId>", SK = "TOKEN#<id>"
This keeps the lookup paths explicit:
| Store method | DynamoDB operation | Notes |
|---|---|---|
FindByIdAsync | GetItem | Direct metadata lookup |
FindByReferenceIdAsync | GetItem plus GetItem | Strongly consistent lookup item, then metadata |
FindBySubjectAsync | Query on GSI2 | Result set based query |
FindByApplicationIdAsync | 10 parallel Query calls on GSI3 | Scatter query across token id shards |
FindByAuthorizationIdAsync | Query on GSI4 | Used when cleaning authorization related tokens |
The store does not use Scan for these methods. That is the main line I wanted to keep clear.
Performance considerations
The production rule is simple: OAuth request paths use GetItem and Query only.
GetItem is O(1) by key. Query is O(N) where N is the result set under the selected partition key. Scan is different. It reads table or index data page by page and consumes capacity for the data evaluated before any filters are applied. It is fine for maintenance jobs, but it should not sit on OAuth request paths because cost and latency grow with the amount of data that must be examined.
OpenIddict has APIs that naturally map to scans:
ListAsyncCountAsyncPruneAsync
Those methods are implemented because the store contract requires them, but they are admin and maintenance paths. OpenIddict Server and Validation handlers do not call them during normal OAuth flows.
PruneAsync is the one that deserves special attention. In OpenIddict it is usually run by a Quartz background job. That means it can be scheduled, throttled, and observed separately from token issuance.
TTL on the tokens table is a cleanup helper, not a correctness feature. DynamoDB TTL deletion is asynchronous. The store still checks token expiration during validation.
A practical deployment uses CloudWatch alarms on throttles, transaction conflicts, and latency percentiles per table. Since tokens have their own table, token pressure is visible immediately.
Registering the DynamoDB stores
The package follows the same shape as other OpenIddict store integrations. Register OpenIddict core services, then add the DynamoDB stores.
services.AddOpenIddict()
.AddCore(options =>
{
options.AddDynamoDb(dynamo =>
{
dynamo.ApplicationsTableName = "OpenIddictApplications";
dynamo.AuthorizationsTableName = "OpenIddictAuthorizations";
dynamo.ScopesTableName = "OpenIddictScopes";
dynamo.TokensTableName = "OpenIddictTokens";
});
});
For a quick local test, install the package and point the AWS SDK client at DynamoDB Local.
dotnet add package ahanoff.OpenIddict.DynamoDb
The package also includes a table creator helper for development and tests.
TableCreator is not meant to be a production migration tool. For real environments, define tables, GSIs, TTL, alarms, and IAM permissions with CDK, Pulumi, Terraform, or CloudFormation.
A production setup should make the table design explicit in infrastructure code. That also makes it easier to review GSI projections, enable point in time recovery, and keep test tables separate from live tables.
Summary
The biggest lesson from this implementation is that OpenIddict storage is not generic CRUD when it runs on DynamoDB. The API shape is repository like, but the correctness requirements come from OAuth and the performance requirements come from DynamoDB.
The main decisions were:
- Use four tables, because applications, authorizations, scopes, and tokens have different lifecycles and throughput profiles.
- Use materialized lookup items for correctness sensitive reads such as
ClientIdandReferenceId. - Use
TransactWriteItemswhenever metadata and lookup items must be written together. - Treat authorization status as the source of truth, and use token fanout updates only as cleanup.
- Shard token application queries by token id, not application id, to avoid hot partitions.
- Keep
Scanout of OAuth request paths. It belongs in admin and maintenance code only.
The source code is available on GitHub, and the package is published as ahanoff.OpenIddict.DynamoDb. It is still early, but the core design is shaped around the constraints that matter in production: predictable reads, atomic writes, and DynamoDB access patterns that are easy to reason about.