pika-auth
pika-auth
Authentication strategies for Pika — plug-in Bearer token, API key, and HTTP Basic auth with class-level or per-resource control.
Installation
Add to your shard.yml:
dependencies:
pika-auth:
github: tekanic/pika-auth
version: "~> 0.1"
Then run shards install.
Usage
require "pika-auth"
Include the module
class MyAPI < Pika::API
include Pika::Auth
# ...
end
include Pika::Auth installs a before hook that runs on every request. By itself it does nothing — you need to define at least one strategy.
Class-level auth
Use the auth macro to set a default strategy for the entire API class.
class MyAPI < Pika::API
include Pika::Auth
auth :bearer do |token|
token == ENV["API_TOKEN"]
end
resource :users do
get { User.all.to_json }
end
end
Every request to every route in MyAPI must now supply a valid Bearer token.
Per-resource overrides
public_resource — no auth required
class MyAPI < Pika::API
include Pika::Auth
auth :bearer do |token|
Token.valid?(token)
end
public_resource :health do
get { {status: "ok"}.to_json }
end
resource :users do
get { User.all.to_json } # bearer required
end
end
GET /health is completely open; all other routes still require a Bearer token.
resource_auth — different strategy per resource
class MyAPI < Pika::API
include Pika::Auth
auth :bearer do |token|
UserToken.valid?(token)
end
resource_auth :webhooks, :api_key do |key|
key == ENV["WEBHOOK_SECRET"]
end do
post { handle_webhook }
end
end
POST /webhooks is authenticated with the api_key strategy; everything else uses the bearer default.
Strategies
BearerToken
Reads the token from Authorization: Bearer <token>.
auth :bearer do |token|
Token.find_by(value: token) != nil
end
ApiKey
Reads the key from a header (default X-API-Key) and/or a query parameter. The header is checked first.
auth :api_key do |key|
key == ENV["API_KEY"]
end
Custom header and/or query param (register manually):
Pika::Auth.register(
:api_key,
Pika::Auth::Strategies::ApiKey.new(
validator: ->(k : String) { k == ENV["API_KEY"] },
header: "X-My-Key",
query_param: "key"
)
)
Basic
Reads credentials from Authorization: Basic <base64(username:password)>.
auth :basic do |username, password|
username == "admin" && password == ENV["ADMIN_PASSWORD"]
end
Error handling
All strategies raise Pika::UnauthorizedError on failure. Pika converts this to a 401 Unauthorized response with a JSON body by default:
{"error": "Unauthorized"}
Multiple strategies in one API
You can register several named strategies and mix them across resources:
class MyAPI < Pika::API
include Pika::Auth
auth :bearer do |token|
UserToken.valid?(token)
end
resource_auth :internal, :api_key do |key|
key == ENV["INTERNAL_KEY"]
end do
get { InternalData.all.to_json }
end
public_resource :health do
get { "ok" }
end
resource :users do
get { User.all.to_json } # bearer (class default)
end
end
| Path | Strategy |
|---|---|
GET /health |
none (public) |
GET /users |
bearer (class default) |
GET /internal |
api_key (per-resource) |
Patterns & recipes
Common patterns that come up when building real APIs. These are application-layer concerns — pika-auth keeps the validator block simple so you can plug in whichever approach fits your stack.
JWT with expiry
JWT tokens carry an exp claim that encodes the expiry time. Validate and check it inside the validator block. If you need a JWT shard, crystal-jwt is the standard choice.
require "jwt"
auth :bearer do |token|
payload, _header = JWT.decode(token, ENV["JWT_SECRET"], JWT::Algorithm::HS256)
payload["exp"].as_i > Time.utc.to_unix
rescue JWT::ExpiredSignatureError | JWT::VerificationError | JWT::DecodeError
false
end
The rescue false pattern keeps the validator block self-contained — any invalid or tampered token returns false and pika-auth raises UnauthorizedError → 401.
To also carry user identity through to handlers, read claims from the token and stash them on the request:
auth :bearer do |token|
payload, _ = JWT.decode(token, ENV["JWT_SECRET"], JWT::Algorithm::HS256)
next false if payload["exp"].as_i <= Time.utc.to_unix
env.request.headers["X-Current-User-Id"] = payload["sub"].as_s
true
rescue JWT::ExpiredSignatureError | JWT::VerificationError | JWT::DecodeError
false
end
resource :users do
get do
user_id = env.request.headers["X-Current-User-Id"]
{id: user_id}.to_json
end
end
Note:
envis available inside the validator block via closure.
Opaque tokens with database lookup
For non-JWT tokens (random strings stored in a tokens table):
auth :bearer do |token|
record = Token.find_by(value: token)
record && record.expires_at > Time.utc && !record.revoked?
end
This pattern gives you full server-side control — you can revoke a token instantly by flipping a flag in the database, with no need to wait for a JWT to expire.
Token expiry and refresh flow
When a short-lived access token expires, clients receive a 401. The standard pattern is:
- Client stores a long-lived refresh token alongside the short-lived access token
- On
401, client sends the refresh token to a dedicated endpoint - Server validates the refresh token, issues a new access token (and optionally rotates the refresh token)
- Client retries the original request with the new access token
In Pika, the refresh endpoint is a public_resource so it bypasses the auth check:
class MyAPI < Pika::API
include Pika::Auth
auth :bearer do |token|
AccessToken.valid?(token) # checks expiry, signature, revocation
end
# Public — no bearer token required to reach this
public_resource :auth do
desc "Exchange credentials for tokens"
params do
requires email : String
requires password : String
end
post do
user = User.authenticate(declared_params.email, declared_params.password)
raise Pika::UnauthorizedError.new unless user
{
access_token: AccessToken.issue(user), # short-lived, e.g. 15 minutes
refresh_token: RefreshToken.issue(user), # long-lived, e.g. 30 days
}.to_json
end
desc "Refresh an access token"
namespace :refresh do
params do
requires refresh_token : String
end
post do
record = RefreshToken.find_valid(declared_params.refresh_token)
raise Pika::UnauthorizedError.new unless record
record.rotate! # invalidate old refresh token, issue a new one
{
access_token: AccessToken.issue(record.user),
refresh_token: record.new_token,
}.to_json
end
end
end
resource :users do
get { User.all.to_json } # bearer required
end
end
Routes:
| Route | Auth | Purpose |
|---|---|---|
POST /auth |
none | Login — returns access + refresh token |
POST /auth/refresh |
none | Exchange refresh token for new access token |
GET /users |
bearer | Protected resource |
API key scoping
If your API keys need scope restrictions (e.g. a key that can only read, not write), check scopes inside the validator and raise Pika::ForbiddenError for insufficient permissions:
auth :api_key do |key|
record = ApiKey.find_by(value: key)
raise Pika::ForbiddenError.new unless record&.can_write?
!record.nil?
end
Or register separate named strategies per scope and assign them with resource_auth:
auth :read_key do |key|
ApiKey.find_by(value: key, scope: "read") != nil
end
# write_key strategy registered separately
Pika::Auth.register(:write_key, Pika::Auth::Strategies::ApiKey.new(
->(k : String) { ApiKey.find_by(value: k, scope: "write") != nil }
))
resource :reports do
get { Report.all.to_json } # read_key (class default)
end
resource_auth :reports, :write_key do |key|
ApiKey.find_by(value: key, scope: "write") != nil
end do
post { Report.create!(declared_params).to_json }
end
HTTP Basic for internal/admin routes only
A common pattern is using HTTP Basic for a small set of internal or admin endpoints while the main API uses Bearer tokens.
class MyAPI < Pika::API
include Pika::Auth
auth :bearer do |token|
UserToken.valid?(token)
end
resource_auth :admin, :basic do |username, password|
username == ENV["ADMIN_USER"] && password == ENV["ADMIN_PASSWORD"]
end do
get { AdminStats.summary.to_json }
post { AdminAction.run(declared_params) }
end
resource :users do
get { User.all.to_json } # bearer
end
end
Webhook signature verification
Webhooks from third-party services (GitHub, Stripe, etc.) are typically verified by comparing an HMAC signature in a header against a shared secret, not by a Bearer token. Use resource_auth with a custom ApiKey strategy to read the signature header:
Pika::Auth.register(
:github_webhook,
Pika::Auth::Strategies::ApiKey.new(
validator: ->(sig : String) {
expected = "sha256=" + OpenSSL::HMAC.hexdigest(:sha256, ENV["GITHUB_WEBHOOK_SECRET"], raw_body)
Crypto::Subtle.constant_time_compare(sig, expected)
},
header: "X-Hub-Signature-256",
query_param: nil
)
)
resource_auth :webhooks, :github_webhook do |sig|
# validator already registered above — block here is the resource body
end do
post { handle_github_event(env) }
end
Note: For webhook signature verification you need the raw request body before it is parsed. Store it on
envin abeforeblock and reference it in your validator closure.
License
MIT
pika-auth
- 0
- 0
- 0
- 0
- 1
- about 3 hours ago
- May 5, 2026
Tue, 05 May 2026 11:49:10 GMT