Day 13: Adding authentication - API keys done simply
Day 13 of 30. Today users can sign up, log in, and get API keys.
Day 13 of 30. Today users can sign up, log in, and get API keys.
Our landing page has a “Get Started” button. It currently goes to a 404. Oops :)
Let’s fix that!
Authentication strategy
We need two kinds of auth:
1. Dashboard auth. Users can log in to see their usage, manage keys, and update billing. This will be session-based auth with social login providers like Google and GitHub.
2. API auth. Developers authenticate API requests with API keys. The calls are stateless, key-based, and suitable for server-to-server calls, or any form of automation.
Since these are both different problems, they require different solutions.
See the auth flows
Explore how OAuth login and API key validation work:
Dashboard: social login with OAuth providers
We’re keeping it simple. No don’t have a username/password login at this moment, we use OAuth only (“Sign in with Google” or “Sign in with GitHub”).
Why do we currently have no username/password?
- This adds a dependency on external email providers for verification
- Password reset flows are complex to build securely
- We’d need to store and encrypt passwords properly
- Our users are mostly developers - you likely have a GitHub or Google account already
If users really need username/password, or other forms of authentication, please let us know, we’ll add it later. For now, we have a support link for edge cases, so please contact us if the above options are not sufficient.
The user model
Our user entity stores OAuth provider information instead of passwords:
User:
id: UUID (primary key)
email: string (unique)
name: string (optional)
oauthProvider: enum (GOOGLE, GITHUB)
oauthProviderId: string (the user's ID from the provider)
plan: enum (FREE, PRO, SCALE), default FREE
createdAt: timestamp
lastLoginAt: timestamp (optional)
OAuth flow
The OAuth process works like this:
When user clicks "Sign in with Google":
1. Redirect to Google's OAuth authorization URL
- Include our client ID, redirect URI, and requested scopes (email, profile)
2. User authenticates with Google and grants permission
3. Google redirects back to our callback URL with an authorization code
4. We exchange the code for access tokens (server-side)
5. We fetch the user's profile from Google using the access token
6. Look up user by oauthProvider + oauthProviderId:
- If exists: update lastLoginAt, create session
- If not exists: create new user, create session
7. Redirect to dashboard
Spring Security’s OAuth2 client handles most of this automatically, so we can focus on configuring the providers and implement the user registration logic.
Session management
We use HTTP-only cookies with session IDs. This ensures that JavaScript cannot read (and thus steal) the information. The security configuration:
Security rules:
- API routes (/api/v1/**): permit all (uses API key auth)
- Auth routes (/auth/**, /oauth2/**): permit all
- Public pages (/, /signup, /login): permit all
- Dashboard routes (/dashboard/**): require authenticated session
Session settings:
- Create session only when needed
- Cookie: HTTP-only, Secure, SameSite=Lax
API keys: the real auth
For Screenshot API requests, we use API keys. They’re simpler than JWT and easier for developers to use.
API key format
Our keys look like:
sk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Components:
sk_- secret key (vs.pk_for publishable keys)live_- environment (vs.test_for sandbox)- 32 random alphanumeric characters
This format is inspired by Stripe. It’s self-documenting: you can tell what a key is just by looking at it.
Storing keys securely
We never store raw API keys, only the hashes, which we again do for security reasons.
Our API key looks somewhat like this:
ApiKey:
id: UUID
userId: UUID (foreign key)
keyPrefix: string ("sk_live_a1b2c3d4")
keyHash: string (SHA-256 hash)
name: string ("Production Key")
createdAt: timestamp
lastUsedAt: timestamp
revokedAt: timestamp (null = active)
When a user creates a key:
- Generate 24 random bytes using secure random
- Base64 encode to get 32 alphanumeric chars
- Prepend “sk_live_” to create full key
- Compute SHA-256 hash
- Store prefix and hash only
- Return full key to user once
- Warn them to save it. They won’t see the key again, but creating a new one is easy.
Validating API keys
validateApiKey(rawKey):
1. Compute SHA-256 hash of the provided key
2. Look up by hash where revokedAt is null
3. If not found: return null (invalid)
4. Update lastUsedAt timestamp
5. Return the ApiKey record
API key authentication filter
When a user makes a request, we inspect the API requests and validate the key:
For each request to /api/v1/**:
1. Extract "X-API-Key" header
2. If missing:
Return 401 with {"error": {"code": "unauthorized", "message": "Missing API key"}}
3. Validate the key
4. If invalid:
Return 401 with {"error": {"code": "unauthorized", "message": "Invalid API key"}}
5. Attach apiKey and userId to request context
6. Continue to actual endpoint
Rate limiting
We’re using a simple token bucket rate limiter (note: below numbers can vary, these are just for illustrative purposes):
| Plan | Rate Limit | Refill Rate |
|---|---|---|
| Free | 100 tokens/hour | ~1.7/minute |
| Pro | 1,000 tokens/hour | ~17/minute |
| Scale | 10,000 tokens/hour | ~167/minute |
Bucket:
tokens: number (current tokens available)
lastRefill: timestamp
checkRateLimit(key, maxTokens, refillRate):
1. Get or create bucket for this key
2. Calculate tokens to add since lastRefill (elapsed time × refillRate)
3. Add tokens, but cap at maxTokens
4. Update lastRefill to now
5. If tokens < 1: return false (rate limited)
6. Subtract 1 token
7. Return true (allowed)
The token bucket approach is nice because it allows short bursts while enforcing overall limits. A user can spend their tokens quickly, then wait for refill.
We’ll move to Redis-based rate limiting when we need distributed rate limits across multiple servers. For now, single-server, in-memory is fine.
The signup flow
- User clicks “Sign in with Google” (or GitHub)
- OAuth flow happens (see above)
- On the first login, we create their account
- Automatically create their first API key so it can be used in the playground and API
- Redirect to dashboard showing the key (with “copy” button and warning to save it)
onOAuthSuccess(oauthUser):
1. Find or create user from OAuth profile
2. If new user:
- Create "Default Key" API key
- Store raw key in session temporarily for display
3. Create authenticated session
4. Redirect to dashboard
- If new user: show welcome modal with API key
- If existing user: show normal dashboard
What we built today
- OAuth login with Google and GitHub
- Session-based dashboard auth
- API key generation and secure storage
- API key validation
- Basic rate limiting
Users can now sign up and get API keys! Try it out here: https://dashboard.allscreenshots.com
Tomorrow: the dashboard
On day 14 we’ll extend the dashboard where users manage keys and see their API usage.
Book of the day
OAuth 2 in Action by Justin Richer and Antonio Sanso
While a bit of an older book, this book passed the test of time well. If you’re implementing OAuth (like we did today), this book explains the protocol thoroughly. It covers the authorization code flow, token handling, and common security pitfalls.
The first few chapters give you a solid mental model of how OAuth works and why it’s designed the way it is. The later chapters dive into building both clients and servers. Even if you’re using a framework that handles most of it (like Spring Security), understanding the underlying flow helps you debug issues and make better security decisions.