@everystack/security
v0.2.0
Published
Device attestation, biometric auth, and cryptographic identity for Expo apps
Readme
@everystack/security
Device attestation (Apple App Attest, Google Play Integrity), RS256 JWT, JWKS, challenge-response verification, and cryptographic utilities for Expo apps.
Install
pnpm add @everystack/security drizzle-ormEntry Points
| Import | Description |
|--------|-------------|
| @everystack/security | Server-side: handler, verifier, stores, JWT, crypto |
| @everystack/security/client | Client-side: attestation providers, biometric auth |
| @everystack/security/schema | Drizzle tables (challenges, device_keys) |
| @everystack/security/crypto | Hash (SHA-256) and random utilities |
Server: Verifier
The verifier handles the challenge-response flow for device attestation:
import { createVerifier } from '@everystack/security';
const verifier = createVerifier({
apple: {
appId: 'TEAM_ID.com.example.app',
development: process.env.NODE_ENV !== 'production',
},
google: {
packageName: 'com.example.app',
decryptionKey: process.env.GOOGLE_DECRYPTION_KEY,
verificationKey: process.env.GOOGLE_VERIFICATION_KEY,
},
challengeTtlMs: 5 * 60 * 1000, // 5 minutes
});
// 1. Client requests a challenge
const { challengeId, challenge, expiresAt } = await verifier.createChallenge(deviceId);
// 2. Client performs attestation with the challenge, sends result back
// 3. Server verifies attestation
const result = await verifier.verifyAttestation('ios', attestationData, challengeId, keyId);
// → { keyId, publicKey, counter, environment, appId }
// 4. For subsequent requests, verify assertions
const assertion = await verifier.verifyAssertion(assertionData, clientData, keyId);
// → { valid: true, counter }Certificate Chain Validation
The Apple attestation verifier validates the full certificate chain from the leaf certificate through the intermediate to Apple's root CA. This ensures attestation responses genuinely originate from Apple's servers.
In production, the chain is validated using @peculiar/x509 — forged certificates are rejected. For development/testing, chain validation can be explicitly bypassed:
const verifier = createVerifier({
apple: {
appId: 'TEAM_ID.com.example.app',
skipChainValidation: true, // Only for development — never in production
},
});The nonce extension is extracted from the attestation certificate using a proper ASN.1 parser (by OID), not raw byte pattern matching.
Persistent Stores
Use Drizzle-backed stores for production (in-memory stores are for testing):
import {
createVerifier,
DrizzleChallengeStore,
DrizzleKeyStore,
} from '@everystack/security';
const verifier = createVerifier({
apple: { appId: 'TEAM_ID.com.example.app' },
challengeStore: new DrizzleChallengeStore(db),
keyStore: new DrizzleKeyStore(db),
});Server: HTTP Handler
Expose attestation endpoints as a Web Standard handler:
import { createSecurityHandler } from '@everystack/security';
const handler = createSecurityHandler({
verifierConfig: {
apple: { appId: 'TEAM_ID.com.example.app' },
challengeStore: new DrizzleChallengeStore(db),
keyStore: new DrizzleKeyStore(db),
},
jwt: {
current: { kid: 'key-1', publicKey, privateKey },
previous: [], // For key rotation
},
basePath: '/api/security',
auth: { verifyToken: async (token) => verifyJWT(token) },
});Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | /challenges | Create a new challenge |
| POST | /verify | Verify device attestation |
| POST | /assert | Verify assertion (subsequent requests) |
| GET | /.well-known/jwks.json | JWKS public key endpoint |
Mounting
// app/api/security/[...path]+api.ts
export function GET(request: Request) { return handler(request); }
export function POST(request: Request) { return handler(request); }JWT
RS256 JWT signing, verification, and key management:
import { signJWT, verifyJWT, generateKeyPair, getJWKS } from '@everystack/security';
// Generate RSA key pair
const { publicKey, privateKey } = await generateKeyPair();
// Sign a token
const token = await signJWT(
{ sub: 'user-123', role: 'admin' },
privateKey,
{ kid: 'key-1', expiresIn: '1h' },
);
// Verify a token
const payload = await verifyJWT(token, publicKey);
// → { sub: 'user-123', role: 'admin', iat: ..., exp: ... }
// Decode without verification (for debugging)
const decoded = decodeJWT(token);
// Generate JWKS for public key distribution
const jwks = getJWKS([{ kid: 'key-1', publicKey, privateKey }]);
// Serve at /.well-known/jwks.jsonKey Rotation
const handler = createSecurityHandler({
jwt: {
current: { kid: 'key-2', publicKey: newKey, privateKey: newPrivate },
previous: [
{ kid: 'key-1', publicKey: oldKey, privateKey: oldPrivate },
],
},
});
// JWKS includes both keys; tokens signed with either are validCrypto Utilities
import { sha256, sha256Bytes } from '@everystack/security/crypto';
import { randomUUID, randomBytes, randomHex } from '@everystack/security/crypto';
// Hashing
const hash = await sha256('hello world'); // hex string
const bytes = await sha256Bytes('hello world'); // Uint8Array
// Random generation
const uuid = randomUUID(); // UUID v4
const bytes = randomBytes(32); // 32 random bytes
const hex = randomHex(16); // 16-byte hex stringRuntime Requirements
All random generation functions require crypto.randomUUID() or crypto.getRandomValues() to be available. There is no Math.random() fallback — if the Web Crypto API is unavailable, the functions throw a descriptive error.
This is intentional: Math.random() is not cryptographically secure and must never be used for security-sensitive IDs (tokens, challenge nonces, key identifiers).
React Native: The Web Crypto API is available in Hermes (React Native 0.74+). For older versions, install a polyfill such as react-native-get-random-values and import it before any @everystack/security imports:
import 'react-native-get-random-values'; // Must be first
import { randomUUID } from '@everystack/security/crypto';Stores
Challenge Store
Manages attestation challenges with TTL:
interface ChallengeStore {
create(deviceId: string | undefined, ttlMs: number): Promise<Challenge>;
verify(id: string, challenge: string): Promise<boolean>;
}Implementations:
InMemoryChallengeStore— For testingDrizzleChallengeStore— PostgreSQL via Drizzle (uses atomicUPDATE ... WHERE revokedAt IS NULL RETURNING *to prevent race conditions — a challenge can only be consumed once, even under concurrent requests)
Key Store
Stores device public keys for assertion verification:
interface KeyStore {
save(key: DeviceKey): Promise<void>;
get(keyId: string): Promise<DeviceKey | null>;
updateCounter(keyId: string, counter: number): Promise<void>;
}Implementations:
InMemoryKeyStore— For testingDrizzleKeyStore— PostgreSQL via Drizzle
Schema
Add security tables to your Drizzle migrations:
import { challenges, deviceKeys } from '@everystack/security/schema';Client SDK
For React Native apps, the client SDK provides attestation providers:
import { AttestationProvider, useBiometricAuth } from '@everystack/security/client';See the client entry point for device attestation flow integration with Expo.
Peer Dependencies
| Package | Version | Required |
|---------|---------|----------|
| drizzle-orm | >=0.35.0 | For Drizzle stores |
| expo-constants | >=16.0.0 | Client SDK |
| expo-local-authentication | >=14.0.0 | Biometric auth |
| react | >=18.0.0 | Client SDK |
| react-native | >=18.0.0 | Client SDK |
Part of everystack — a self-hosted application stack for Expo apps on AWS.
License
MIT
